50 Commits

Author SHA1 Message Date
arifal
9b3889ef1f partial update create nginx proxy https 2025-06-26 17:19:21 +07:00
arifal
fc98479362 fix remove filter base on user dealer id 2025-06-25 18:11:26 +07:00
arifal
38def0dc9c fix styling select2 dropdown on mutations and opnames 2025-06-25 17:23:45 +07:00
arifal
e5daafc8f0 add more seeder product and product category and fix daterangepicker 2025-06-25 16:29:34 +07:00
arifal
e96ca0a83c partial update close modal on all page and disable create transaction with no stock 2025-06-25 14:01:21 +07:00
arifal
c3233ea6b2 partial update transaction work with stock product 2025-06-24 19:42:19 +07:00
arifal
33502e905d fix shadow border on datatable 2025-06-20 15:48:53 +07:00
arifal
41ae7da60e fix icon and condition header show when children have access 2025-06-20 15:42:04 +07:00
arifal
334b9acd87 fix styling opnames and mutations same with stock audit 2025-06-20 15:29:34 +07:00
arifal
0de5bec84a fix style section filter 2025-06-20 13:16:16 +07:00
arifal
82f9d7f466 create print opname and mutations 2025-06-19 18:02:20 +07:00
arifal
e478dc81bb create export product stock dealers 2025-06-19 17:35:35 +07:00
arifal
22477b6dab add filter date and dealer on mutations and opnames 2025-06-19 16:45:41 +07:00
arifal
b803068d0e fix orderable datatable on mutations and products index 2025-06-16 19:01:11 +07:00
arifal
aa233eb793 create new menu histori stock audit 2025-06-16 17:27:59 +07:00
arifal
567e4aa5fc localize library cdn, remove approve button from transaction page, fix all fitur running datatable as well, fixing sortable datatable using new cdn, fix modal approve, add note to receiver mutations 2025-06-16 15:01:08 +07:00
arifal
9cfb566aee remove status pending and complete 2025-06-15 02:29:26 +07:00
arifal
3fb598ae4d fix permission on local using root 2025-06-13 18:29:16 +07:00
arifal
e9566d4c8a fix cdn use and nginx restrict cdn 2025-06-13 16:52:37 +07:00
arifal
4517f7efcb fix cdn library 2025-06-13 16:33:36 +07:00
arifal
ec8224760e fix handle using asset for access css 2025-06-13 16:10:03 +07:00
arifal
ac55ed1b67 fix redirect url to port 2025-06-13 15:30:19 +07:00
arifal
6625baf7bd npm run production 2025-06-13 15:17:49 +07:00
arifal
2f5eff9e63 fix handle error and add note for shippings receive approve and reject mutations 2025-06-13 14:19:12 +07:00
arifal
b2bfd666a7 fix nginx config for server demo 2025-06-13 00:13:43 +07:00
arifal
680eb2045a disable worker autorun 2025-06-12 23:43:02 +07:00
arifal
ca7a0b941e update docker demo server 2025-06-12 23:32:43 +07:00
arifal
e64cf43390 fix nginx proxy for server 2025-06-12 23:19:50 +07:00
arifal
bba37c1720 fix port already use on server 2025-06-12 22:54:55 +07:00
arifal
520c0e9885 fix mysql version for production deocker image 2025-06-12 22:37:12 +07:00
arifal
2fa60c583a fix redirect active tab after submit opname mutations and receive mutations 2025-06-12 18:19:26 +07:00
arifal
b04b8f88cb add backup file and autobackup code, partial update mutations receive on transation page 2025-06-12 18:09:13 +07:00
arifal
58578532cc fix edit products using new workflow mutations 2025-06-12 17:15:06 +07:00
arifal
1a01efb1b5 partial update create mutations workflow 2025-06-12 15:10:51 +07:00
arifal
a5e1348436 partial update create mutations 2025-06-12 00:33:59 +07:00
arifal
0b211915f1 add update nginx config to domain and create production setup docker 2025-06-11 19:02:02 +07:00
root
647aa51187 partial update stock opname feature 2025-06-11 18:29:32 +07:00
root
9b25a772a6 fix permission and trouble on mysql docker 2025-06-11 13:43:24 +07:00
arifal
f92655e3e2 add docker for local and production 2025-06-10 22:29:30 +07:00
arifal
84fb7ffb52 add status in opname datatable with order by created at desc 2025-06-10 19:12:21 +07:00
arifal
51079aa567 create stock and stock logs 2025-06-10 18:38:06 +07:00
arifal
1a2ddb59d4 update backupdb local 2025-06-05 19:07:20 +07:00
arifal
d294bb7876 partial update detail opnames page 2025-06-05 15:18:20 +07:00
arifal
ce0a4718e0 partial update create modal list dealers 2025-06-05 12:05:20 +07:00
arifal
ff498cd98f partial update opnames and detail table 2025-06-04 18:29:05 +07:00
arifal
8305e44c42 partial update products 2025-06-04 16:58:50 +07:00
arifal
215792ce63 partial update create page opnames 2025-06-03 12:56:33 +07:00
arifal
a881779c4f partial update create toggle active product and mutations 2025-06-02 18:51:04 +07:00
arifal
6bf8bc4965 fix structure product categories table and crud product 2025-06-02 16:21:33 +07:00
arifal
59e23ae535 create crud product categories and partial update crud products with dealers stock 2025-05-28 18:24:44 +07:00
1748 changed files with 42887 additions and 14368 deletions

52
.dockerignore Executable file
View File

@@ -0,0 +1,52 @@
# Git
.git
.gitignore
README.md
# Docker files
Dockerfile*
docker-compose*
.dockerignore
# Development files
.env.local
.env.development
.env.staging
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE files
.vscode
.idea
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Laravel specific
storage/app/*
!storage/app/.gitignore
storage/framework/cache/*
!storage/framework/cache/.gitignore
storage/framework/sessions/*
!storage/framework/sessions/.gitignore
storage/framework/views/*
!storage/framework/views/.gitignore
storage/logs/*
!storage/logs/.gitignore
bootstrap/cache/*
!bootstrap/cache/.gitignore
# Backup files
*.zip
*.tar.gz
*.sql
# Test files
tests/
phpunit.xml

0
.editorconfig Normal file → Executable file
View File

0
.env.example Normal file → Executable file
View File

0
.gitattributes vendored Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
.styleci.yml Normal file → Executable file
View File

356
BACKUP_README.md Executable file
View File

@@ -0,0 +1,356 @@
# 🗃️ CKB Database Backup & Restore Guide
Panduan lengkap untuk backup dan restore database Docker CKB menggunakan mysqldump.
## 📋 Informasi Database
- **Container Name**: `ckb-mysql-dev`
- **Database Name**: `ckb_db`
- **Database Type**: MariaDB 10.6
- **Port**: 3306
- **Username**: `root` / `laravel`
- **Password**: `root` / `password`
## 🛠️ Available Scripts
### 1. **backup_db.sh** - Simple Backup
Script backup sederhana dengan kompresi opsional.
```bash
./backup_db.sh
```
**Features:**
- ✅ Backup database dengan timestamp
- ✅ Kompresi opsional (gzip)
- ✅ Validasi container status
- ✅ Progress indicator
### 2. **restore_db.sh** - Database Restore
Script restore dengan interface interaktif.
```bash
./restore_db.sh
```
**Features:**
- ✅ List backup files yang tersedia
- ✅ Support compressed & uncompressed files
- ✅ Konfirmasi sebelum restore
- ✅ Automatic decompression
### 3. **backup_advanced.sh** - Advanced Backup
Script backup lengkap dengan berbagai opsi.
```bash
./backup_advanced.sh
```
**Features:**
- ✅ Multiple backup types (Full, Structure, Data)
- ✅ Automatic cleanup old backups
- ✅ Colored output
- ✅ Backup statistics
- ✅ Compression with ratio info
## 🚀 Quick Start
### Basic Backup
```bash
# Simple backup
./backup_db.sh
# Manual backup command
docker exec ckb-mysql-dev mysqldump -u root -proot ckb_db > backup.sql
```
### Advanced Backup
```bash
# Interactive advanced backup
./backup_advanced.sh
# Full backup with all options
docker exec ckb-mysql-dev mysqldump -u root -proot \
--single-transaction \
--routines \
--triggers \
--add-drop-table \
ckb_db > full_backup.sql
```
### Restore Database
```bash
# Interactive restore
./restore_db.sh
# Manual restore
docker exec -i ckb-mysql-dev mysql -u root -proot ckb_db < backup.sql
# Restore compressed backup
gunzip -c backup.sql.gz | docker exec -i ckb-mysql-dev mysql -u root -proot ckb_db
```
## 📂 Backup Types
### 1. **Full Backup** (Default)
```bash
docker exec ckb-mysql-dev mysqldump -u root -proot \
--single-transaction \
--routines \
--triggers \
--add-drop-table \
ckb_db > full_backup.sql
```
### 2. **Structure Only**
```bash
docker exec ckb-mysql-dev mysqldump -u root -proot \
--no-data \
--routines \
--triggers \
ckb_db > structure_backup.sql
```
### 3. **Data Only**
```bash
docker exec ckb-mysql-dev mysqldump -u root -proot \
--no-create-info \
--single-transaction \
ckb_db > data_backup.sql
```
### 4. **Compressed Backup**
```bash
docker exec ckb-mysql-dev mysqldump -u root -proot ckb_db | gzip > backup.sql.gz
```
## ⚙️ Manual Commands
### Basic Commands
```bash
# Check container status
docker ps | grep ckb-mysql-dev
# Backup with timestamp
docker exec ckb-mysql-dev mysqldump -u root -proot ckb_db > ckb_backup_$(date +%Y%m%d_%H%M%S).sql
# Backup all databases
docker exec ckb-mysql-dev mysqldump -u root -proot --all-databases > all_databases.sql
# Backup specific tables
docker exec ckb-mysql-dev mysqldump -u root -proot ckb_db table1 table2 > specific_tables.sql
```
### Advanced Options
```bash
# Backup with extended options
docker exec ckb-mysql-dev mysqldump -u root -proot \
--single-transaction \
--routines \
--triggers \
--events \
--add-drop-table \
--add-drop-trigger \
--hex-blob \
--complete-insert \
ckb_db > advanced_backup.sql
# Backup without locking tables (for MyISAM)
docker exec ckb-mysql-dev mysqldump -u root -proot \
--lock-tables=false \
ckb_db > no_lock_backup.sql
# Backup with conditions
docker exec ckb-mysql-dev mysqldump -u root -proot \
--where="created_at >= '2023-01-01'" \
ckb_db table_name > conditional_backup.sql
```
## 🔄 Automated Backups
### Cron Job Setup
```bash
# Edit crontab
crontab -e
# Daily backup at 2 AM
0 2 * * * /path/to/backup_db.sh
# Weekly full backup on Sunday at 3 AM
0 3 * * 0 /path/to/backup_advanced.sh
# Monthly cleanup (keep last 30 days)
0 4 1 * * find /path/to/backups -name "*.sql*" -mtime +30 -delete
```
### Systemd Timer (Alternative to Cron)
Create `/etc/systemd/system/ckb-backup.service`:
```ini
[Unit]
Description=CKB Database Backup
After=docker.service
[Service]
Type=oneshot
ExecStart=/path/to/backup_db.sh
User=root
```
Create `/etc/systemd/system/ckb-backup.timer`:
```ini
[Unit]
Description=Run CKB backup daily
Requires=ckb-backup.service
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
```
Enable timer:
```bash
sudo systemctl enable ckb-backup.timer
sudo systemctl start ckb-backup.timer
```
## 📊 Backup Management
### Check Backup Size
```bash
# Show backup directory size
du -sh backups/
# List all backups with sizes
ls -lah backups/
# Show compression ratio
for file in backups/*.sql.gz; do
original_size=$(gunzip -l "$file" | tail -1 | awk '{print $2}')
compressed_size=$(stat -c%s "$file")
ratio=$(echo "scale=1; (1 - $compressed_size/$original_size) * 100" | bc)
echo "$(basename "$file"): ${ratio}% compression"
done
```
### Cleanup Old Backups
```bash
# Delete backups older than 30 days
find backups/ -name "*.sql*" -mtime +30 -delete
# Keep only last 10 backups
ls -1t backups/ckb_backup_*.sql* | tail -n +11 | xargs rm -f
# Clean by size (keep if total < 1GB)
total_size=$(du -s backups/ | cut -f1)
if [ $total_size -gt 1048576 ]; then
# Delete oldest files until under 1GB
echo "Cleaning up old backups..."
fi
```
## 🚨 Troubleshooting
### Common Issues
1. **Container not running**
```bash
# Start container
docker-compose up -d db
# Check logs
docker logs ckb-mysql-dev
```
2. **Permission denied**
```bash
# Fix script permissions
chmod +x *.sh
# Fix backup directory permissions
sudo chown -R $USER:$USER backups/
```
3. **Out of disk space**
```bash
# Check disk usage
df -h
# Clean old backups
find backups/ -name "*.sql*" -mtime +7 -delete
```
4. **MySQL connection error**
```bash
# Test connection
docker exec ckb-mysql-dev mysql -u root -proot -e "SELECT 1;"
# Check MySQL status
docker exec ckb-mysql-dev mysqladmin -u root -proot status
```
## 🔐 Security Notes
1. **Never store passwords in plain text** - Consider using MySQL config files
2. **Encrypt sensitive backups** - Use GPG for production environments
3. **Secure backup storage** - Store backups in secure, offsite locations
4. **Regular restore testing** - Test backups regularly to ensure they work
## 📈 Best Practices
1. **Regular Backups**: Daily for production, weekly for development
2. **Multiple Backup Types**: Keep both full and incremental backups
3. **Offsite Storage**: Store backups in different physical/cloud locations
4. **Compression**: Use gzip to save storage space
5. **Retention Policy**: Define how long to keep backups
6. **Monitoring**: Monitor backup success/failure
7. **Documentation**: Document backup procedures and recovery steps
8. **Testing**: Regularly test restore procedures
## 📝 File Structure
```
backups/
├── ckb_backup_20231225_143022.sql.gz # Full backup (compressed)
├── ckb_structure_20231225_143022.sql # Structure only
├── ckb_data_20231225_143022.sql.gz # Data only (compressed)
└── README.md # This documentation
Scripts:
├── backup_db.sh # Simple backup script
├── backup_advanced.sh # Advanced backup with options
├── restore_db.sh # Interactive restore script
└── BACKUP_README.md # This documentation
```
---
**Made with ❤️ for CKB Project**

169
DATABASE-IMPORT-GUIDE.md Executable file
View File

@@ -0,0 +1,169 @@
# 📊 Database Import Guide untuk CKB Laravel Application
## 🚀 Quick Start (Paling Mudah)
Jika Anda baru pertama kali setup aplikasi:
```bash
# Jalankan quick setup yang otomatis import database
./docker-quick-setup.sh dev
```
## 📥 Manual Import Database
### 1. Import ke Development Environment
```bash
# Pastikan containers berjalan terlebih dahulu
./docker-start.sh dev up
# Import database ckb.sql
./docker-import-db.sh dev
# Atau import file SQL lain
./docker-import-db.sh dev nama-file-backup.sql
```
### 2. Import ke Production Environment
```bash
# Start production environment
./docker-start.sh prod up
# Import database
./docker-import-db.sh prod
# Atau dengan file khusus
./docker-import-db.sh prod production-backup.sql
```
## 🔄 Auto Import (Recommended untuk First Time Setup)
Ketika Anda menjalankan Docker containers untuk pertama kali, file `ckb.sql` akan otomatis diimport ke database. Ini terjadi karena:
1. File `ckb.sql` di-mount ke `/docker-entrypoint-initdb.d/01-init.sql` di MySQL container
2. MySQL otomatis menjalankan semua file `.sql` di direktori tersebut saat inisialisasi
3. Auto import hanya terjadi jika database kosong/belum ada
## 🛠️ Troubleshooting Import
### Problem: Database tidak terimport otomatis
**Solusi:**
```bash
# 1. Stop containers
docker-compose down
# 2. Hapus volume database (HATI-HATI: akan hapus data!)
docker-compose down -v
# 3. Start ulang (akan trigger auto import)
docker-compose up -d
# 4. Atau import manual
./docker-import-db.sh dev
```
### Problem: Permission denied saat import
**Solusi:**
```bash
# Pastikan script executable
chmod +x docker-import-db.sh
chmod +x docker-quick-setup.sh
# Pastikan file SQL readable
chmod 644 ckb.sql
```
### Problem: Database terlalu besar, import timeout
**Solusi:**
```bash
# Import langsung ke container dengan timeout yang lebih besar
docker-compose exec -T db mysql -u root -proot ckb_db < ckb.sql
# Atau split file SQL jika sangat besar
split -l 10000 ckb.sql ckb_split_
# Kemudian import satu per satu
```
## 📋 Verifikasi Import Berhasil
### 1. Cek via phpMyAdmin
- Buka http://localhost:8080
- Login dengan: server=db, username=root, password=root
- Pilih database `ckb_db`
- Lihat tabel yang sudah terimport
### 2. Cek via Command Line
```bash
# Lihat daftar tabel
docker-compose exec db mysql -u root -proot -e "USE ckb_db; SHOW TABLES;"
# Hitung jumlah tabel
docker-compose exec db mysql -u root -proot -e "USE ckb_db; SELECT COUNT(*) as total_tables FROM information_schema.tables WHERE table_schema='ckb_db';"
# Lihat contoh data dari salah satu tabel
docker-compose exec db mysql -u root -proot -e "USE ckb_db; SELECT * FROM users LIMIT 5;"
```
### 3. Test Aplikasi Laravel
```bash
# Cek koneksi database dari Laravel
docker-compose exec app php artisan tinker
# Di dalam tinker:
# DB::connection()->getPdo();
# \App\Models\User::count();
```
## 💾 Backup Database
### Backup Development
```bash
# Backup dengan timestamp
docker-compose exec db mysqldump -u root -proot ckb_db > backup_dev_$(date +%Y%m%d_%H%M%S).sql
# Backup sederhana
docker-compose exec db mysqldump -u root -proot ckb_db > backup_current.sql
```
### Backup Production
```bash
# Backup production database
docker-compose -f docker-compose.prod.yml exec db mysqldump -u root -p ckb_production > backup_prod_$(date +%Y%m%d_%H%M%S).sql
```
## 🔄 Replace Database dengan Backup Baru
```bash
# 1. Backup database saat ini (safety)
docker-compose exec db mysqldump -u root -proot ckb_db > backup_before_replace.sql
# 2. Import database baru
./docker-import-db.sh dev new-backup.sql
# 3. Clear Laravel cache
docker-compose exec app php artisan cache:clear
docker-compose exec app php artisan config:clear
```
## 📝 Notes Penting
1. **File ckb.sql**: Pastikan file ini selalu ada di root project untuk auto-import
2. **Backup Safety**: Script import otomatis membuat backup sebelum replace database
3. **Environment**: Selalu pastikan Anda menggunakan environment yang benar (dev/prod)
4. **Permissions**: Database user harus punya permission CREATE, DROP, INSERT untuk import
5. **Size Limit**: File SQL besar (>100MB) mungkin perlu setting timeout MySQL yang lebih besar
## 🎯 Best Practices
1. **Selalu backup** sebelum import database baru
2. **Test di development** dulu sebelum import ke production
3. **Gunakan quick setup** untuk setup pertama kali
4. **Monitor logs** saat import: `docker-compose logs -f db`
5. **Verify data** setelah import berhasil
---
**Untuk bantuan lebih lanjut, lihat file `DOCKER-README.md` atau `docker-import-db.sh --help`**

289
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,289 @@
# CKB Application Deployment Guide
## Overview
This guide explains how to deploy the CKB Laravel application with Docker, SSL certificate, and reverse proxy configuration.
## Prerequisites
- Ubuntu/Debian server
- Docker and Docker Compose installed
- Domain pointing to server IP
- Nginx installed on main server
- Root/sudo access
## Architecture
```
Internet → Nginx (Port 80/443) → Docker Container (Port 8082) → Laravel App
```
## File Structure
```
/var/www/ckb/
├── docker-compose.prod.yml # Docker services configuration
├── Dockerfile # Laravel app container
├── docker/
│ ├── nginx-proxy.conf # Internal nginx proxy
│ ├── php.ini # PHP configuration
│ ├── mysql.cnf # MySQL configuration
│ └── supervisord.conf # Process manager
├── nginx-ckb-reverse-proxy.conf # Main server nginx config
├── deploy-ckb.sh # Deployment script
├── setup-ssl.sh # SSL certificate setup script
└── DEPLOYMENT.md # This file
```
## Container Names and Volumes
All containers and volumes are prefixed with `ckb-` to avoid conflicts:
### Containers:
- `ckb-laravel-app` - Laravel application
- `ckb-mariadb` - Database
- `ckb-redis` - Cache/Queue
- `ckb-nginx-proxy` - Internal nginx proxy
### Volumes:
- `ckb_mysql_data` - Database data
- `ckb_redis_data` - Redis data
- `ckb_nginx_logs` - Nginx logs
- `ckb_storage_logs` - Laravel logs
- `ckb_storage_cache` - Laravel cache
## Step-by-Step Deployment
### Step 1: Prepare the Application
```bash
cd /var/www/ckb
# Make scripts executable
chmod +x deploy-ckb.sh
chmod +x setup-ssl.sh
```
### Step 2: Deploy Docker Application
```bash
# Run deployment script
./deploy-ckb.sh
```
This script will:
- Stop existing containers
- Build and start new containers
- Check if containers are running
- Verify port 8082 is accessible
### Step 3: Setup SSL Certificate
```bash
# Run SSL setup script (requires sudo)
sudo ./setup-ssl.sh
```
This script will:
- Install certbot if not present
- Create temporary nginx configuration
- Generate Let's Encrypt certificate
- Update nginx with SSL configuration
- Setup auto-renewal
### Step 4: Manual Verification
```bash
# Check if containers are running
docker ps | grep ckb
# Check if port 8082 is accessible
curl -I http://localhost:8082
# Check SSL certificate
sudo certbot certificates
# Test HTTPS access
curl -I https://bengkel.digitaloasis.xyz
```
## Configuration Files
### docker-compose.prod.yml
- Updated container names with `ckb-` prefix
- Removed certbot service (handled by main server)
- Updated APP_URL to use HTTPS
- Specific volume names to avoid conflicts
### nginx-proxy.conf
- Simplified configuration (no SSL handling)
- Proxy to `ckb-app` container
- Rate limiting and security headers
- Static file caching
### nginx-ckb-reverse-proxy.conf
- Main server nginx configuration
- SSL termination
- Reverse proxy to port 8082
- Security headers and SSL settings
## Environment Variables
Create `.env` file in `/var/www/ckb/`:
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://bengkel.digitaloasis.xyz
DB_DATABASE=ckb_production
DB_USERNAME=laravel
DB_PASSWORD=your_password
DB_ROOT_PASSWORD=your_root_password
REDIS_PASSWORD=your_redis_password
```
## Monitoring and Maintenance
### View Logs
```bash
# Docker logs
docker-compose -f docker-compose.prod.yml logs -f
# Nginx logs (main server)
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
# Laravel logs
docker exec ckb-laravel-app tail -f /var/www/html/storage/logs/laravel.log
```
### SSL Certificate Renewal
```bash
# Manual renewal
sudo certbot renew
# Check renewal status
sudo certbot certificates
```
### Container Management
```bash
# Restart all services
docker-compose -f docker-compose.prod.yml restart
# Update application
git pull
docker-compose -f docker-compose.prod.yml up -d --build
# Stop all services
docker-compose -f docker-compose.prod.yml down
# Remove all data (WARNING: This will delete all data)
docker-compose -f docker-compose.prod.yml down -v
```
## Troubleshooting
### Port 8082 Not Accessible
```bash
# Check if container is running
docker ps | grep ckb-nginx-proxy
# Check container logs
docker-compose -f docker-compose.prod.yml logs ckb-nginx-proxy
# Check if port is bound
netstat -tlnp | grep 8082
```
### SSL Certificate Issues
```bash
# Check certificate status
sudo certbot certificates
# Test certificate
sudo certbot renew --dry-run
# Check nginx configuration
sudo nginx -t
```
### Database Connection Issues
```bash
# Check database container
docker exec ckb-mariadb mysql -u root -p -e "SHOW DATABASES;"
# Check Laravel database connection
docker exec ckb-laravel-app php artisan tinker
```
### Permission Issues
```bash
# Fix Laravel permissions
docker exec ckb-laravel-app chown -R www-data:www-data /var/www/html
docker exec ckb-laravel-app chmod -R 775 /var/www/html/storage
docker exec ckb-laravel-app chmod -R 775 /var/www/html/bootstrap/cache
```
## Security Considerations
1. **Firewall**: Ensure only necessary ports are open
2. **SSL**: Certificate auto-renewal is configured
3. **Rate Limiting**: Configured for login and API endpoints
4. **Security Headers**: HSTS, XSS protection, etc.
5. **File Permissions**: Proper Laravel file permissions
6. **Database**: Strong passwords and limited access
## Backup Strategy
### Database Backup
```bash
# Create backup
docker exec ckb-mariadb mysqldump -u root -p ckb_production > backup.sql
# Restore backup
docker exec -i ckb-mariadb mysql -u root -p ckb_production < backup.sql
```
### Application Backup
```bash
# Backup application files
tar -czf ckb-backup-$(date +%Y%m%d).tar.gz /var/www/ckb/
# Backup volumes
docker run --rm -v ckb_mysql_data:/data -v $(pwd):/backup alpine tar czf /backup/mysql-backup.tar.gz -C /data .
```
## Performance Optimization
1. **Nginx**: Gzip compression enabled
2. **Laravel**: Production optimizations
3. **Database**: Proper indexing
4. **Redis**: Caching and session storage
5. **Static Files**: Long-term caching headers
## Support
For issues or questions:
1. Check logs first
2. Verify configuration files
3. Test connectivity step by step
4. Check system resources
5. Review security settings

404
DOCKER-README.md Executable file
View File

@@ -0,0 +1,404 @@
# Docker Setup untuk CKB Laravel Application
Dokumentasi ini menjelaskan cara menjalankan aplikasi CKB menggunakan Docker untuk environment local development dan staging/production.
## Struktur File Docker
```
├── Dockerfile # Production/Staging Docker image
├── Dockerfile.dev # Development Docker image
├── docker-compose.yml # Local development setup
├── docker-compose.prod.yml # Production/Staging setup
├── .dockerignore # Files to exclude from build
└── docker/
├── env.example # Environment variables template
├── nginx.conf # Production Nginx config
├── nginx.dev.conf # Development Nginx config
├── supervisord.conf # Production supervisor config
├── supervisord.dev.conf # Development supervisor config
├── xdebug.ini # Xdebug configuration
├── php.ini # PHP configuration
└── mysql.cnf # MySQL configuration
```
## Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+
- Git
## Setup untuk Local Development
### 1. Quick Setup (Recommended)
Untuk setup cepat dengan auto-import database:
```bash
# Clone repository
git clone <your-repo-url>
cd CKB
# Pastikan file ckb.sql ada di root project
ls ckb.sql
# Jalankan quick setup
./docker-quick-setup.sh dev
```
Script ini akan otomatis:
- Setup environment file
- Start semua containers
- Import database dari ckb.sql
- Generate application key
- Setup Laravel application
### 2. Manual Setup
Jika Anda ingin setup manual:
```bash
# Setup local environment
./docker-setup-env.sh local
# Start containers
docker-compose up -d --build
# Import database
./docker-import-db.sh dev
# Generate Laravel application key
docker-compose exec app php artisan key:generate
```
### 2. Menjalankan Development Environment
```bash
# Build dan jalankan containers
docker-compose up -d --build
# Atau tanpa rebuild
docker-compose up -d
```
### 3. Akses Aplikasi
- **Web Application**: http://localhost:8000
- **Database (phpMyAdmin)**: http://localhost:8080
- **Mail Testing (MailHog)**: http://localhost:8025
- **MySQL Direct**: localhost:3306
- **Redis**: localhost:6379
### 4. Menjalankan Laravel Commands
```bash
# Masuk ke container aplikasi
docker-compose exec app bash
# Atau jalankan command langsung
docker-compose exec app php artisan migrate
docker-compose exec app php artisan db:seed
docker-compose exec app php artisan cache:clear
```
### 5. Development dengan Hot Reload
```bash
# Install dependencies
docker-compose exec app npm install
# Jalankan webpack dev server
docker-compose exec app npm run hot
```
## Setup untuk Staging/Production
### 1. Persiapan Environment
```bash
# Copy dan edit environment file production
cp docker/env.example .env.production
# Edit file .env.production sesuai kebutuhan production
vim .env.production
```
Contoh konfigurasi production:
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.com
DB_HOST=db
DB_DATABASE=ckb_production
DB_USERNAME=your_db_user
DB_PASSWORD=your_secure_password
DB_ROOT_PASSWORD=your_root_password
REDIS_PASSWORD=your_redis_password
```
### 2. Menjalankan Production Environment
```bash
# Build dan jalankan dengan konfigurasi production
docker-compose -f docker-compose.prod.yml up -d --build
# Atau menggunakan environment file spesifik
docker-compose -f docker-compose.prod.yml --env-file .env.production up -d --build
```
### 3. Database Migration dan Seeding
```bash
# Jalankan migrations
docker-compose -f docker-compose.prod.yml exec app php artisan migrate --force
# Jalankan seeders (jika diperlukan)
docker-compose -f docker-compose.prod.yml exec app php artisan db:seed --force
# Optimize aplikasi untuk production
docker-compose -f docker-compose.prod.yml exec app php artisan config:cache
docker-compose -f docker-compose.prod.yml exec app php artisan route:cache
docker-compose -f docker-compose.prod.yml exec app php artisan view:cache
```
## Monitoring dan Debugging
### 1. Melihat Logs
```bash
# Semua services
docker-compose logs -f
# Service specific
docker-compose logs -f app
docker-compose logs -f db
docker-compose logs -f redis
# Production
docker-compose -f docker-compose.prod.yml logs -f
```
### 2. Debugging dengan Xdebug (Development)
Xdebug sudah dikonfigurasi untuk development environment:
- Port: 9003
- IDE Key: PHPSTORM
- Host: host.docker.internal
### 3. Monitoring Resources
```bash
# Lihat resource usage
docker stats
# Lihat containers yang berjalan
docker-compose ps
```
## Database Management
### 1. Import Database dari Backup
Untuk mengimport database dari file backup ckb.sql:
```bash
# Import ke development environment
./docker-import-db.sh dev
# Import ke production environment
./docker-import-db.sh prod
# Import file SQL khusus
./docker-import-db.sh dev my-backup.sql
```
Script import akan otomatis:
- Backup database yang sudah ada (safety)
- Drop dan recreate database
- Import data dari file SQL
- Jalankan migrations jika diperlukan
- Clear cache Laravel
### 2. Backup Database
```bash
# Backup database development
docker-compose exec db mysqldump -u root -proot ckb_db > backup_$(date +%Y%m%d).sql
# Backup database production
docker-compose -f docker-compose.prod.yml exec db mysqldump -u root -p ckb_production > backup_prod_$(date +%Y%m%d).sql
```
### 3. Manual Restore Database
```bash
# Restore database development
docker-compose exec -T db mysql -u root -proot ckb_db < backup.sql
# Restore database production
docker-compose -f docker-compose.prod.yml exec -T db mysql -u root -p ckb_production < backup.sql
```
### 4. Auto Import saat Pertama Kali Setup
File `ckb.sql` di root project akan otomatis diimport saat pertama kali menjalankan containers baru. Ini terjadi karena MySQL menggunakan `/docker-entrypoint-initdb.d/` untuk auto-import.
## Troubleshooting
### 1. Docker Build Issues
Jika mengalami error saat build (seperti PHP extension compilation errors):
```bash
# Clean rebuild dengan script otomatis
./docker-rebuild.sh dev
# Atau manual cleanup dan rebuild
docker-compose down
docker system prune -a -f
docker-compose build --no-cache --pull
```
**Common Build Errors:**
- **curl extension error**: Fixed dengan menambah `libcurl4-openssl-dev` dan `pkg-config`
- **gd extension error**: Pastikan `libfreetype6-dev` dan `libjpeg62-turbo-dev` terinstall
- **Out of space**: Jalankan `docker system prune -a -f` untuk cleanup
### 2. Permission Issues
**Laravel Storage Permission Errors** (seperti "laravel.log could not be opened"):
```bash
# Quick fix dengan script otomatis
./docker-fix-permissions.sh dev
# Atau manual fix
docker-compose exec app chown -R www-data:www-data /var/www/html/storage
docker-compose exec app chmod -R 775 /var/www/html/storage
docker-compose exec app mkdir -p /var/www/html/storage/logs
```
**Host Permission Issues:**
```bash
# Fix permission di host system
sudo chown -R $(id -u):$(id -g) storage/
sudo chown -R $(id -u):$(id -g) bootstrap/cache/
chmod -R 775 storage/
chmod -R 775 bootstrap/cache/
```
### 3. Reset Containers
```bash
# Stop dan remove containers
docker-compose down
# Remove volumes (HATI-HATI: akan menghapus data database)
docker-compose down -v
# Rebuild dari awal
docker-compose up -d --build --force-recreate
```
### 4. Cache Issues
```bash
# Clear semua cache Laravel
docker-compose exec app php artisan optimize:clear
# Clear Docker build cache
docker system prune -f
# Clean rebuild everything
./docker-rebuild.sh dev
```
### 5. Database Import Issues
```bash
# Jika auto-import gagal
./docker-import-db.sh dev
# Jika database corrupt
docker-compose down -v
docker-compose up -d
./docker-import-db.sh dev
```
### 6. Redis Connection Issues
Jika mengalami error "Class Redis not found":
```bash
# Test Redis functionality
./docker-test-redis.sh dev
# Rebuild containers dengan Redis extension
./docker-rebuild.sh dev
# Manual fix: Clear cache dan config
docker-compose exec app php artisan config:clear
docker-compose exec app php artisan cache:clear
```
**Common Redis Errors:**
- **Class Redis not found**: Fixed dengan install `pecl install redis`
- **Connection refused**: Pastikan Redis container berjalan
- **Config not loaded**: Jalankan `php artisan config:clear`
## Security Notes untuk Production
1. **Environment Variables**: Jangan commit file `.env` ke repository
2. **Database Passwords**: Gunakan password yang kuat
3. **SSL/TLS**: Setup SSL certificate untuk HTTPS
4. **Firewall**: Konfigurasi firewall untuk membatasi akses port
5. **Updates**: Regular update Docker images dan dependencies
## Performance Optimization
### 1. Production Optimizations
```bash
# Laravel optimizations
docker-compose exec app php artisan config:cache
docker-compose exec app php artisan route:cache
docker-compose exec app php artisan view:cache
docker-compose exec app composer install --optimize-autoloader --no-dev
```
### 2. Docker Optimizations
- Gunakan multi-stage builds untuk image yang lebih kecil
- Leverage Docker layer caching
- Optimize .dockerignore untuk build speed
## Backup Strategy
### 1. Automated Backups
Buat script backup otomatis:
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
docker-compose exec db mysqldump -u root -p${DB_ROOT_PASSWORD} ckb_production > "backup_${DATE}.sql"
tar -czf "backup_${DATE}.tar.gz" backup_${DATE}.sql storage/
```
### 2. Volume Backups
```bash
# Backup Docker volumes
docker run --rm -v ckb_mysql_data:/data -v $(pwd):/backup alpine tar czf /backup/mysql_backup.tar.gz /data
```
Untuk pertanyaan lebih lanjut atau issues, silakan buat issue di repository ini.

85
Dockerfile Executable file
View File

@@ -0,0 +1,85 @@
FROM php:8.1-fpm
# Set working directory
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libcurl4-openssl-dev \
pkg-config \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libxpm-dev \
libvpx-dev \
supervisor \
nginx \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-xpm \
&& docker-php-ext-install -j$(nproc) \
curl \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
dom \
xml
# Install Redis extension
RUN pecl install redis \
&& docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy existing application directory contents
COPY . /var/www/html
# Copy existing application directory permissions
COPY --chown=www-data:www-data . /var/www/html
# Install PHP dependencies
RUN composer install --optimize-autoloader --no-dev --no-interaction
# Install Node.js dependencies and build assets
RUN npm ci \
&& npm run production \
&& rm -rf node_modules
# Create necessary directories and set permissions
RUN mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/storage/framework/cache \
&& mkdir -p /var/www/html/storage/framework/sessions \
&& mkdir -p /var/www/html/storage/framework/views \
&& mkdir -p /var/www/html/storage/app \
&& mkdir -p /var/www/html/bootstrap/cache \
&& chown -R www-data:www-data /var/www/html \
&& chmod -R 775 /var/www/html/storage \
&& chmod -R 775 /var/www/html/bootstrap/cache \
&& chmod -R 755 /var/www/html/public
# Create nginx config
COPY ./docker/nginx.conf /etc/nginx/sites-available/default
# Create supervisor config
COPY ./docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose port 9000 and start php-fpm server
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

89
Dockerfile.dev Executable file
View File

@@ -0,0 +1,89 @@
FROM php:8.1-fpm
# Set working directory
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libcurl4-openssl-dev \
pkg-config \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libxpm-dev \
libvpx-dev \
supervisor \
nginx \
nodejs \
npm \
vim \
nano \
htop \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-xpm \
&& docker-php-ext-install -j$(nproc) \
curl \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
dom \
xml
# Install Redis and Xdebug for development
RUN pecl install redis xdebug \
&& docker-php-ext-enable redis xdebug
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy existing application directory contents
COPY . /var/www/html
# Copy existing application directory permissions
COPY --chown=www-data:www-data . /var/www/html
# Install PHP dependencies with dev packages
RUN composer install --optimize-autoloader --no-interaction
# Install Node.js dependencies
RUN npm install
# Create necessary directories and set permissions
RUN mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/storage/framework/cache \
&& mkdir -p /var/www/html/storage/framework/sessions \
&& mkdir -p /var/www/html/storage/framework/views \
&& mkdir -p /var/www/html/storage/app \
&& mkdir -p /var/www/html/bootstrap/cache \
&& chown -R www-data:www-data /var/www/html \
&& chmod -R 775 /var/www/html/storage \
&& chmod -R 775 /var/www/html/bootstrap/cache \
&& chmod -R 755 /var/www/html/public
# Create nginx config for development
COPY ./docker/nginx.dev.conf /etc/nginx/sites-available/default
# Create supervisor config for development
COPY ./docker/supervisord.dev.conf /etc/supervisor/conf.d/supervisord.conf
# Create Xdebug config
COPY ./docker/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Expose ports
EXPOSE 80 3000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

276
ENVIRONMENT-SETUP.md Executable file
View File

@@ -0,0 +1,276 @@
# Environment Setup Guide
Panduan lengkap untuk setup environment file CKB Laravel Application dengan file template terpisah untuk local dan production.
## 📂 File Structure
```
docker/
├── env.example.local # Template untuk local development
├── env.example.production # Template untuk production
└── (env.example) # File lama, dapat dihapus
```
## 🔧 Quick Setup
### Local Development
```bash
# Setup environment untuk local development
./docker-setup-env.sh local
# Atau manual copy
cp docker/env.example.local .env
```
### Production Deployment
```bash
# Setup environment untuk production
./docker-setup-env.sh production
# IMPORTANT: Edit .env dan ganti semua CHANGE_THIS_* values!
nano .env
```
## 📋 Template Comparison
### 🏠 Local Development (`env.example.local`)
| Setting | Value | Description |
| ------------------- | ----------------------- | ----------------------- |
| `APP_ENV` | `local` | Development environment |
| `APP_DEBUG` | `true` | Debug mode enabled |
| `APP_URL` | `http://localhost:8000` | Local URL |
| `LOG_LEVEL` | `debug` | Verbose logging |
| `DB_DATABASE` | `ckb_db` | Development database |
| `DB_USERNAME` | `root` | Simple credentials |
| `DB_PASSWORD` | `root` | Simple credentials |
| `REDIS_PASSWORD` | `null` | No password needed |
| `MAIL_HOST` | `mailhog` | Local mail testing |
| `QUEUE_CONNECTION` | `sync` | Synchronous queue |
| `TELESCOPE_ENABLED` | `true` | Debugging tool enabled |
### 🚀 Production (`env.example.production`)
| Setting | Value | Description |
| ------------------- | ---------------------------------- | ----------------------- |
| `APP_ENV` | `production` | Production environment |
| `APP_DEBUG` | `false` | Debug mode disabled |
| `APP_URL` | `https://bengkel.digitaloasis.xyz` | Production domain |
| `LOG_LEVEL` | `error` | Error-only logging |
| `DB_DATABASE` | `ckb_production` | Production database |
| `DB_USERNAME` | `ckb_user` | Secure username |
| `DB_PASSWORD` | `CHANGE_THIS_*` | **Must be changed!** |
| `REDIS_PASSWORD` | `CHANGE_THIS_*` | **Must be changed!** |
| `MAIL_HOST` | `smtp.gmail.com` | Real SMTP server |
| `QUEUE_CONNECTION` | `redis` | Redis-based queue |
| `TELESCOPE_ENABLED` | `false` | Debugging tool disabled |
## 🔐 Security Configuration for Production
### Required Changes
**MUST CHANGE** these values in production `.env`:
```env
# Strong database passwords
DB_PASSWORD=your_super_secure_password_here
DB_ROOT_PASSWORD=your_root_password_here
# Redis security
REDIS_PASSWORD=your_redis_password_here
# Mail configuration
MAIL_USERNAME=your-email@domain.com
MAIL_PASSWORD=your-app-specific-password
```
### Optional but Recommended
```env
# AWS S3 for file storage
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret
# Real-time features
PUSHER_APP_ID=your-pusher-app-id
PUSHER_APP_KEY=your-pusher-key
PUSHER_APP_SECRET=your-pusher-secret
```
## 🛠️ Environment Helper Script
### Usage
```bash
# Setup local environment
./docker-setup-env.sh local
# Setup production environment
./docker-setup-env.sh production
# Show current environment info
./docker-setup-env.sh
```
### Features
- ✅ **Auto-backup** existing `.env` before changes
- ✅ **Environment validation** checks required variables
- ✅ **Security warnings** for production misconfiguration
- ✅ **Configuration summary** shows current settings
- ✅ **Next steps guidance** for deployment
## 📊 Environment Comparison
### Local Development Features
- 🐛 **Debug Mode**: Full error reporting and debugging tools
- 📧 **MailHog**: Local email testing server
- 🗄️ **Simple DB**: Basic MySQL credentials
- 🔓 **No SSL**: HTTP-only for speed
- 🧪 **Development Tools**: Telescope, Debugbar enabled
- ⚡ **Sync Queue**: Immediate processing for testing
### Production Features
- 🔒 **Security First**: Strong passwords and encryption
- 📧 **Real SMTP**: Professional email delivery
- 🗄️ **Secure DB**: Production-grade credentials
- 🔐 **SSL/HTTPS**: Let's Encrypt certificates
- 📊 **Monitoring**: Error-only logging
- 🚀 **Redis Queue**: Background job processing
## 🚨 Common Issues & Solutions
### 1. "CHANGE*THIS*\*" Values in Production
**Problem**: Forgot to change template values
```bash
# Check for remaining template values
grep "CHANGE_THIS" .env
```
**Solution**:
```bash
# Use the helper script to check
./docker-setup-env.sh
# It will warn about CHANGE_THIS_* values
```
### 2. Wrong Environment File
**Problem**: Using local config in production
```bash
# Check current environment
grep "APP_ENV=" .env
```
**Solution**:
```bash
# Recreate with correct template
./docker-setup-env.sh production
```
### 3. Missing Environment Variables
**Problem**: Laravel errors about missing config
```bash
# Validate current .env
./docker-setup-env.sh validate
```
**Solution**: Check required variables list and add missing ones
## 📝 Environment Variables Reference
### Core Application
```env
APP_NAME="CKB Bengkel System"
APP_ENV=production|local
APP_KEY=base64:...
APP_DEBUG=true|false
APP_URL=https://domain.com
```
### Database
```env
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=ckb_production|ckb_db
DB_USERNAME=username
DB_PASSWORD=password
DB_ROOT_PASSWORD=root_password
```
### Cache & Session
```env
REDIS_HOST=redis
REDIS_PASSWORD=password|null
REDIS_PORT=6379
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis|sync
```
### Mail Configuration
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.domain.com
MAIL_PORT=587
MAIL_USERNAME=email@domain.com
MAIL_PASSWORD=password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@domain.com
MAIL_FROM_NAME="${APP_NAME}"
```
### Security
```env
TRUSTED_PROXIES=*
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
```
## 🔄 Migration Guide
### From Old Single Template
If you're migrating from the old `docker/env.example`:
```bash
# Backup current .env
cp .env .env.backup
# Choose appropriate template
./docker-setup-env.sh local # for development
./docker-setup-env.sh production # for production
# Compare and migrate custom settings
diff .env.backup .env
```
## 📞 Support
For environment setup issues:
- **Documentation**: This file
- **Helper Script**: `./docker-setup-env.sh`
- **Validation**: Built-in security checks
- **Backup**: Automatic .env backup before changes
---
**💡 Pro Tip**: Always use the helper script `./docker-setup-env.sh` instead of manual copying to ensure proper configuration and security checks!

225
PERMISSION-FIX-GUIDE.md Executable file
View File

@@ -0,0 +1,225 @@
# 🔧 Laravel Permission Fix Guide untuk Docker
## 🎯 Masalah yang Diselesaikan
**Error yang umum terjadi:**
```
The stream or file "/var/www/html/storage/logs/laravel.log" could not be opened in append mode: Failed to open stream: Permission denied
```
**Root Cause:**
- Laravel tidak bisa menulis ke direktori `storage/logs/`
- Permission dan ownership direktori storage tidak sesuai
- Direktori storage yang diperlukan belum dibuat
## 🚀 Solusi Quick Fix
### **Option 1: Automatic Fix (Recommended)**
```bash
# Fix semua permission issues otomatis
./docker-fix-permissions.sh dev
# Untuk production
./docker-fix-permissions.sh prod
```
### **Option 2: Manual Fix**
```bash
# Buat direktori yang diperlukan
docker-compose exec app mkdir -p /var/www/html/storage/logs
docker-compose exec app mkdir -p /var/www/html/storage/framework/cache
docker-compose exec app mkdir -p /var/www/html/storage/framework/sessions
docker-compose exec app mkdir -p /var/www/html/storage/framework/views
# Fix ownership
docker-compose exec app chown -R www-data:www-data /var/www/html/storage
docker-compose exec app chown -R www-data:www-data /var/www/html/bootstrap/cache
# Fix permissions
docker-compose exec app chmod -R 775 /var/www/html/storage
docker-compose exec app chmod -R 775 /var/www/html/bootstrap/cache
```
### **Option 3: Rebuild Containers (Jika masalah persisten)**
```bash
# Clean rebuild containers
./docker-rebuild.sh dev
```
## 🔍 Verifikasi Fix Berhasil
### **1. Cek Permission Direktori**
```bash
# Lihat permission storage
docker-compose exec app ls -la /var/www/html/storage/
# Cek ownership logs
docker-compose exec app ls -la /var/www/html/storage/logs/
```
**Output yang benar:**
```
drwxrwxr-x 5 www-data www-data 4096 Jun 10 15:01 storage
drwxrwxr-x 2 www-data www-data 4096 Jun 10 15:01 logs
```
### **2. Test Laravel Logging**
```bash
# Test write ke log
docker-compose exec app php -r "file_put_contents('/var/www/html/storage/logs/laravel.log', 'Test log: ' . date('Y-m-d H:i:s') . PHP_EOL, FILE_APPEND);"
# Cek isi log
docker-compose exec app tail -5 /var/www/html/storage/logs/laravel.log
```
### **3. Test Laravel Artisan**
```bash
# Test cache clear
docker-compose exec app php artisan cache:clear
# Test storage link
docker-compose exec app php artisan storage:link
# Test route cache
docker-compose exec app php artisan route:cache
```
## 🛡️ Prevention - Dockerfile Updates
**Dockerfile sudah diperbarui untuk mencegah masalah ini:**
```dockerfile
# Create necessary directories and set permissions
RUN mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/storage/framework/cache \
&& mkdir -p /var/www/html/storage/framework/sessions \
&& mkdir -p /var/www/html/storage/framework/views \
&& mkdir -p /var/www/html/storage/app \
&& mkdir -p /var/www/html/bootstrap/cache \
&& chown -R www-data:www-data /var/www/html \
&& chmod -R 775 /var/www/html/storage \
&& chmod -R 775 /var/www/html/bootstrap/cache
```
## 🔧 Script Features
### **`docker-fix-permissions.sh`**
- ✅ **Auto-detect environment** (dev/prod)
- ✅ **Create missing directories**
- ✅ **Fix ownership** (www-data:www-data)
- ✅ **Set proper permissions** (775 untuk storage)
- ✅ **Test logging functionality**
- ✅ **Create storage link**
- ✅ **Show before/after permissions**
### **Usage Examples**
```bash
# Fix development environment
./docker-fix-permissions.sh dev
# Fix production environment
./docker-fix-permissions.sh prod
# Show help
./docker-fix-permissions.sh --help
```
## 🚨 Common Issues & Solutions
### **1. Permission Denied setelah Fix**
**Cause:** Volume mounting conflict
**Solution:**
```bash
# Cek volume mounts
docker-compose config
# Restart containers
docker-compose restart app
# Re-run permission fix
./docker-fix-permissions.sh dev
```
### **2. Ownership reverted setelah restart**
**Cause:** Volume mounting dari host
**Solution:**
```bash
# Fix di host system juga
sudo chown -R $(id -u):$(id -g) storage/
chmod -R 775 storage/
# Atau gunakan named volumes di docker-compose
```
### **3. Log file tetap tidak bisa ditulis**
**Cause:** Log file sudah ada dengan permission salah
**Solution:**
```bash
# Hapus log file lama
docker-compose exec app rm -f /var/www/html/storage/logs/laravel.log
# Re-run permission fix
./docker-fix-permissions.sh dev
```
### **4. Selinux/AppArmor blocking**
**Cause:** Security policies
**Solution:**
```bash
# Disable selinux temporarily (CentOS/RHEL)
sudo setenforce 0
# Check AppArmor status (Ubuntu)
sudo aa-status
```
## 📁 Directory Structure yang Benar
Setelah fix, struktur direktori storage harus seperti ini:
```
storage/
├── app/
│ ├── public/
│ └── .gitkeep
├── framework/
│ ├── cache/
│ ├── sessions/
│ ├── testing/
│ └── views/
└── logs/
├── laravel.log
└── .gitkeep
```
## 🎯 Best Practices
1. **Always use scripts**: Gunakan `docker-fix-permissions.sh` untuk consistency
2. **Regular checks**: Monitor permission setelah update containers
3. **Volume strategy**: Gunakan named volumes untuk persistent storage
4. **Backup first**: Backup data sebelum fix permission
5. **Test thoroughly**: Verify semua Laravel functionality setelah fix
## 📞 Troubleshooting Commands
```bash
# Debug permission issues
docker-compose exec app ls -laR /var/www/html/storage/
# Check Laravel configuration
docker-compose exec app php artisan config:show logging
# Monitor Laravel logs
docker-compose exec app tail -f /var/www/html/storage/logs/laravel.log
# Test file writing
docker-compose exec app touch /var/www/html/storage/test.txt
# Check container user
docker-compose exec app whoami
docker-compose exec app id
```
---
**✅ Dengan mengikuti panduan ini, masalah Laravel permission di Docker container akan teratasi.**

360
PRODUCTION-DEPLOYMENT.md Executable file
View File

@@ -0,0 +1,360 @@
# CKB Production Deployment Guide
Panduan deployment aplikasi CKB Laravel ke production server dengan domain `bengkel.digitaloasis.xyz`.
## 🚀 Quick Start
### 1. Deploy ke Production
```bash
# Full deployment (recommended untuk pertama kali)
./docker-deploy-prod.sh deploy
# Hanya build containers
./docker-deploy-prod.sh build
# Setup SSL certificate
./docker-deploy-prod.sh ssl
# Check deployment status
./docker-deploy-prod.sh status
```
### 2. Akses Aplikasi
- **Domain**: https://bengkel.digitaloasis.xyz
- **Health Check**: https://bengkel.digitaloasis.xyz/health
## 📋 Prerequisites
### Server Requirements
- **OS**: Ubuntu 20.04+ atau CentOS 7+
- **Memory**: Minimum 2GB RAM (4GB recommended)
- **Storage**: Minimum 20GB SSD
- **Docker**: Version 20.10+
- **Docker Compose**: Version 2.0+
### Domain Setup
1. **DNS Configuration**:
```
A Record: bengkel.digitaloasis.xyz → [Server IP]
CNAME: www.bengkel.digitaloasis.xyz → bengkel.digitaloasis.xyz
```
2. **Firewall Configuration**:
```bash
# Allow HTTP/HTTPS traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Allow SSH (if needed)
sudo ufw allow 22/tcp
```
## 🛡️ Security Configuration
### 1. Environment Variables
Edit `.env` file untuk production:
```env
# Application
APP_ENV=production
APP_DEBUG=false
APP_URL=https://bengkel.digitaloasis.xyz
APP_KEY=base64:...
# Database (GANTI dengan credentials yang aman!)
DB_HOST=db
DB_DATABASE=ckb_production
DB_USERNAME=ckb_user
DB_PASSWORD=secure_password_here
DB_ROOT_PASSWORD=secure_root_password_here
# Redis
REDIS_HOST=redis
REDIS_PASSWORD=secure_redis_password
# Mail
MAIL_MAILER=smtp
MAIL_HOST=your-smtp-host
MAIL_PORT=587
MAIL_USERNAME=your-email@domain.com
MAIL_PASSWORD=your-email-password
MAIL_ENCRYPTION=tls
# Session & Cache
SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
# Trusted Proxies
TRUSTED_PROXIES=*
```
### 2. Database Security
```bash
# Setelah deployment, jalankan MySQL secure installation
docker-compose -f docker-compose.prod.yml exec db mysql_secure_installation
```
## 🔧 Deployment Process
### Manual Step-by-Step
1. **Persiapan Server**:
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
2. **Clone Repository**:
```bash
git clone https://github.com/your-repo/ckb.git
cd ckb
```
3. **Setup Environment**:
```bash
# For production environment
./docker-setup-env.sh production
# Edit production settings (IMPORTANT!)
nano .env
# Change all CHANGE_THIS_* values with secure passwords
```
4. **Deploy Application**:
```bash
./docker-deploy-prod.sh deploy
```
5. **Setup SSL Certificate**:
```bash
./docker-deploy-prod.sh ssl
```
## 📊 Monitoring & Maintenance
### 1. Health Checks
```bash
# Check application status
./docker-deploy-prod.sh status
# Check specific service logs
docker-compose -f docker-compose.prod.yml logs -f app
docker-compose -f docker-compose.prod.yml logs -f nginx-proxy
docker-compose -f docker-compose.prod.yml logs -f db
```
### 2. Database Backup
```bash
# Manual backup
docker-compose -f docker-compose.prod.yml exec -T db mysqldump -u root -p"$DB_ROOT_PASSWORD" ckb_production > backup_$(date +%Y%m%d).sql
# Automated backup (add to crontab)
0 2 * * * /path/to/ckb/docker-backup.sh
```
### 3. SSL Certificate Renewal
Certificate akan otomatis renewal. Untuk manual renewal:
```bash
# Test renewal
docker-compose -f docker-compose.prod.yml run --rm certbot renew --dry-run
# Manual renewal
./docker-ssl-renew.sh
# Setup auto-renewal (add to crontab)
0 12 * * * /path/to/ckb/docker-ssl-renew.sh
```
## 🔍 Troubleshooting
### Common Issues
1. **Application Not Loading**:
```bash
# Check container status
docker-compose -f docker-compose.prod.yml ps
# Check application logs
docker-compose -f docker-compose.prod.yml logs app
# Restart application
docker-compose -f docker-compose.prod.yml restart app
```
2. **SSL Certificate Issues**:
```bash
# Check certificate status
openssl s_client -connect bengkel.digitaloasis.xyz:443 -servername bengkel.digitaloasis.xyz
# Re-setup SSL
./docker-ssl-setup.sh
```
3. **Database Connection Issues**:
```bash
# Check database logs
docker-compose -f docker-compose.prod.yml logs db
# Test database connection
docker-compose -f docker-compose.prod.yml exec app php artisan tinker
>>> DB::connection()->getPdo();
```
4. **Permission Issues**:
```bash
# Fix Laravel permissions
./docker-fix-permissions.sh prod
```
### Performance Issues
```bash
# Check resource usage
docker stats
# Clean up Docker system
docker system prune -a -f
# Optimize Laravel
docker-compose -f docker-compose.prod.yml exec app php artisan optimize
```
## 🚦 Load Testing
Before going live, test your application:
```bash
# Install testing tools
sudo apt install apache2-utils
# Basic load test
ab -n 1000 -c 10 https://bengkel.digitaloasis.xyz/
# More comprehensive testing with siege
sudo apt install siege
siege -c 25 -t 60s https://bengkel.digitaloasis.xyz/
```
## 📈 Performance Optimization
### 1. Laravel Optimizations
```bash
# Run after each deployment
docker-compose -f docker-compose.prod.yml exec app php artisan config:cache
docker-compose -f docker-compose.prod.yml exec app php artisan route:cache
docker-compose -f docker-compose.prod.yml exec app php artisan view:cache
docker-compose -f docker-compose.prod.yml exec app composer install --optimize-autoloader --no-dev
```
### 2. Database Optimization
```bash
# MySQL tuning
docker-compose -f docker-compose.prod.yml exec db mysql -u root -p -e "
SET GLOBAL innodb_buffer_pool_size = 1073741824;
SET GLOBAL query_cache_size = 67108864;
SET GLOBAL query_cache_type = 1;
"
```
### 3. Nginx Optimization
Edit `docker/nginx-proxy.conf` untuk mengoptimalkan:
- Gzip compression
- Browser caching
- Connection pooling
## 🔄 Updates & Maintenance
### Application Updates
```bash
# Pull latest code
git pull origin main
# Backup before update
./docker-deploy-prod.sh backup
# Deploy updates
./docker-deploy-prod.sh deploy
```
### Security Updates
```bash
# Update base images
docker-compose -f docker-compose.prod.yml pull
# Rebuild with latest security patches
./docker-deploy-prod.sh build
```
## 📞 Support & Contact
Untuk bantuan deployment atau issues:
- **Email**: admin@digitaloasis.xyz
- **Documentation**: https://github.com/your-repo/ckb/docs
- **Issues**: https://github.com/your-repo/ckb/issues
## 📄 File Structure
```
ckb/
├── docker/
│ ├── nginx-proxy.conf # Main nginx configuration
│ ├── nginx-temp.conf # Temporary config for SSL setup
│ ├── env.example # Environment template
│ └── ...
├── docker-compose.prod.yml # Production compose file
├── docker-deploy-prod.sh # Main deployment script
├── docker-ssl-setup.sh # SSL certificate setup
├── docker-ssl-renew.sh # SSL renewal script
└── PRODUCTION-DEPLOYMENT.md # This file
```
## ✅ Production Checklist
- [ ] Domain DNS configured
- [ ] Firewall rules configured
- [ ] .env file configured with production values
- [ ] Database credentials changed from defaults
- [ ] SSL certificate obtained and configured
- [ ] Backup system configured
- [ ] Monitoring setup
- [ ] Load testing completed
- [ ] Security audit completed
---
**🚨 Remember**: Always test in staging environment before deploying to production!

0
README.md Normal file → Executable file
View File

277
REDIS-FIX-GUIDE.md Executable file
View File

@@ -0,0 +1,277 @@
# 🔴 Redis Fix Guide untuk Laravel Docker
## 🎯 Masalah yang Diselesaikan
**Error yang dialami:**
```
Class "Redis" not found
```
**Root Cause:**
- PHP Redis extension tidak terinstall di container
- Laravel dikonfigurasi untuk menggunakan Redis tetapi extension tidak tersedia
- Container perlu rebuild untuk install Redis extension
## 🚀 Solusi yang Diimplementasi
### **1. Updated Dockerfiles**
**Production (Dockerfile):**
```dockerfile
# Install Redis extension
RUN pecl install redis \
&& docker-php-ext-enable redis
```
**Development (Dockerfile.dev):**
```dockerfile
# Install Redis and Xdebug for development
RUN pecl install redis xdebug \
&& docker-php-ext-enable redis xdebug
```
### **2. Fix Steps yang Dijalankan**
```bash
# 1. Update Dockerfile dengan Redis extension
# 2. Rebuild container
docker-compose build --no-cache app
# 3. Restart container dengan image baru
docker-compose up -d app
# 4. Verify Redis extension installed
docker-compose exec app php -m | grep redis
# 5. Test Redis connection
docker-compose exec app php -r "
\$redis = new Redis();
\$redis->connect('redis', 6379);
echo 'Redis connected successfully';
"
# 6. Clear Laravel cache
docker-compose exec app php artisan config:clear
docker-compose exec app php artisan cache:clear
```
## ✅ Verifikasi Fix Berhasil
### **1. PHP Redis Extension**
```bash
# Cek extension terinstall
docker-compose exec app php -m | grep redis
# Output: redis
```
### **2. Redis Connection Test**
```bash
# Test koneksi Redis
./docker-test-redis.sh dev
```
**Expected Output:**
```
[SUCCESS] PHP Redis extension is installed
[SUCCESS] Redis server is responding
[SUCCESS] PHP Redis connection working
[SUCCESS] Laravel cache operations working
```
### **3. Web Application**
```bash
# Test web response
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/
# Output: 302 (redirect ke login page)
```
### **4. Laravel Cache Operations**
```bash
# Test Laravel cache dengan Redis
docker-compose exec app php artisan tinker --execute="
Cache::put('test', 'redis-working', 60);
echo Cache::get('test');
"
# Output: redis-working
```
## 🛠️ Tools dan Scripts
### **`docker-test-redis.sh`**
Comprehensive Redis testing script:
- ✅ Test PHP Redis extension
- ✅ Test Redis server connection
- ✅ Test Laravel cache operations
- ✅ Show Redis configuration
- ✅ Show server information
**Usage:**
```bash
# Test development environment
./docker-test-redis.sh dev
# Test production environment
./docker-test-redis.sh prod
```
### **`docker-rebuild.sh`**
Updated untuk include Redis testing:
- ✅ Test Redis extension di build process
- ✅ Verify Redis connection setelah rebuild
- ✅ Comprehensive testing semua extensions
## 🔧 Laravel Configuration
### **Environment Variables (.env)**
```env
# Redis Configuration
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
# Cache using Redis
CACHE_DRIVER=redis
# Sessions using Redis
SESSION_DRIVER=redis
# Queue using Redis
QUEUE_CONNECTION=redis
```
### **Config Files**
Laravel otomatis membaca konfigurasi dari environment variables untuk:
- `config/cache.php` - Cache driver
- `config/session.php` - Session driver
- `config/queue.php` - Queue driver
- `config/database.php` - Redis connection
## 🚨 Common Issues & Solutions
### **1. Redis Extension Missing**
**Symptoms:** `Class "Redis" not found`
**Solution:**
```bash
# Rebuild containers
./docker-rebuild.sh dev
```
### **2. Redis Connection Failed**
**Symptoms:** `Connection refused`
**Solution:**
```bash
# Check Redis container
docker-compose ps | grep redis
# Restart Redis
docker-compose restart redis
# Test connection
./docker-test-redis.sh dev
```
### **3. Laravel Config Not Loading**
**Symptoms:** Cache/session tidak menggunakan Redis
**Solution:**
```bash
# Clear Laravel cache
docker-compose exec app php artisan config:clear
docker-compose exec app php artisan cache:clear
docker-compose exec app php artisan view:clear
```
### **4. Permission Issues with Redis**
**Symptoms:** Cannot write to cache
**Solution:**
```bash
# Fix permissions
./docker-fix-permissions.sh dev
# Clear cache
docker-compose exec app php artisan cache:clear
```
## 📋 Best Practices
### **1. Container Management**
- Always rebuild containers setelah update Dockerfile
- Use scripts untuk consistent operations
- Test functionality setelah changes
### **2. Development Workflow**
```bash
# Complete setup dengan Redis
./docker-quick-setup.sh dev
# Test semua functionality
./docker-test-redis.sh dev
# Fix jika ada issues
./docker-fix-permissions.sh dev
```
### **3. Production Deployment**
```bash
# Build production containers
./docker-rebuild.sh prod
# Verify Redis working
./docker-test-redis.sh prod
# Import database
./docker-import-db.sh prod
```
## 🔍 Monitoring & Debugging
### **Redis Monitoring**
```bash
# Redis logs
docker-compose logs redis
# Redis CLI access
docker-compose exec redis redis-cli
# Redis info
docker-compose exec redis redis-cli info
# Monitor Redis commands
docker-compose exec redis redis-cli monitor
```
### **Laravel Debugging**
```bash
# Check Laravel logs
docker-compose exec app tail -f storage/logs/laravel.log
# Check cache status
docker-compose exec app php artisan cache:table
# Test cache manually
docker-compose exec app php artisan tinker
# Cache::put('test', 'value', 60);
# Cache::get('test');
```
## 📈 Performance Tips
### **1. Redis Optimization**
- Use appropriate data types
- Set proper expiration times
- Monitor memory usage
### **2. Laravel Cache Strategy**
```bash
# Cache configuration
php artisan config:cache
# Cache routes
php artisan route:cache
# Cache views
php artisan view:cache
```
---
**✅ Dengan implementasi fix ini, masalah "Class Redis not found" sudah teratasi dan aplikasi Laravel berjalan normal dengan Redis.**

0
api_bengkel2/git.zip Normal file → Executable file
View File

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CleanMutationsData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mutations:clean {--force : Force cleanup without confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean mutations data to allow migration rollback';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle()
{
if (!$this->option('force')) {
if (!$this->confirm('This will delete ALL mutations data. Are you sure?')) {
$this->info('Operation cancelled.');
return 0;
}
}
try {
DB::beginTransaction();
// Delete mutations data in proper order (foreign key constraints)
$this->info('Cleaning mutations data...');
// 1. Delete stock logs related to mutations
if (Schema::hasTable('stock_logs')) {
$deleted = DB::table('stock_logs')
->where('source_type', 'App\\Models\\Mutation')
->delete();
$this->info("Deleted {$deleted} stock logs related to mutations");
}
// 2. Delete mutation details
if (Schema::hasTable('mutation_details')) {
$deleted = DB::table('mutation_details')->delete();
$this->info("Deleted {$deleted} mutation details");
}
// 3. Delete mutations
if (Schema::hasTable('mutations')) {
$deleted = DB::table('mutations')->delete();
$this->info("Deleted {$deleted} mutations");
}
// 4. Reset auto increment
if (Schema::hasTable('mutations')) {
DB::statement('ALTER TABLE mutations AUTO_INCREMENT = 1');
$this->info('Reset mutations auto increment');
}
if (Schema::hasTable('mutation_details')) {
DB::statement('ALTER TABLE mutation_details AUTO_INCREMENT = 1');
$this->info('Reset mutation_details auto increment');
}
DB::commit();
$this->info('✅ Mutations data cleaned successfully!');
$this->info('You can now rollback and re-run migrations.');
return 0;
} catch (\Exception $e) {
DB::rollback();
$this->error('❌ Error cleaning mutations data: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Models\Mutation;
use App\Models\MutationDetail;
class ClearMutationsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mutations:clear {--force : Force the operation without confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear all mutations and mutation details, then reset auto increment IDs';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle()
{
// Show warning
$this->warn('⚠️ WARNING: This will permanently delete ALL mutations and mutation details!');
$this->warn('⚠️ This action cannot be undone!');
// Check for force flag
if (!$this->option('force')) {
if (!$this->confirm('Are you sure you want to continue?')) {
$this->info('Operation cancelled.');
return 0;
}
}
// Show current counts
$mutationCount = Mutation::count();
$detailCount = MutationDetail::count();
$trashedMutationCount = Mutation::onlyTrashed()->count();
$this->info("Current data:");
$this->info("- Mutations: {$mutationCount}");
$this->info("- Mutation Details: {$detailCount}");
if ($trashedMutationCount > 0) {
$this->info("- Soft Deleted Mutations: {$trashedMutationCount}");
}
if ($mutationCount === 0 && $detailCount === 0 && $trashedMutationCount === 0) {
$this->info('No data to clear.');
return 0;
}
$this->info('Starting cleanup process...');
try {
// Delete data within transaction
DB::beginTransaction();
// Delete mutation details first (foreign key constraint)
$this->info('🗑️ Deleting mutation details...');
MutationDetail::query()->delete();
// Delete mutations (including soft deleted ones)
$this->info('🗑️ Deleting mutations...');
Mutation::query()->delete();
// Force delete soft deleted mutations
$this->info('🗑️ Force deleting soft deleted mutations...');
Mutation::onlyTrashed()->forceDelete();
DB::commit();
// Reset auto increment outside transaction (DDL operations auto-commit)
$this->info('🔄 Resetting mutation_details auto increment...');
DB::statement('ALTER TABLE mutation_details AUTO_INCREMENT = 1');
$this->info('🔄 Resetting mutations auto increment...');
DB::statement('ALTER TABLE mutations AUTO_INCREMENT = 1');
$this->info('✅ Successfully cleared all mutations and reset auto increment!');
$this->info('📊 Final counts:');
$this->info('- Mutations: ' . Mutation::count());
$this->info('- Mutation Details: ' . MutationDetail::count());
return 0;
} catch (\Exception $e) {
if (DB::transactionLevel() > 0) {
DB::rollback();
}
$this->error('❌ Failed to clear mutations: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use App\Models\Opname;
use App\Models\OpnameDetail;
use App\Models\Stock;
use App\Models\StockLog;
class ClearOpnameData extends Command
{
protected $signature = 'opname:clear {--force : Force clear without confirmation}';
protected $description = 'Clear all opname-related data including opnames, details, stocks, logs, and reset all IDs to 1';
public function handle()
{
if (!$this->option('force')) {
if (!$this->confirm('This will delete ALL opname data, stocks, stock logs, and reset ALL IDs to 1. This is irreversible! Are you sure?')) {
$this->info('Operation cancelled.');
return;
}
}
$this->info('Starting complete data cleanup...');
try {
// Disable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
// 1. Clear and reset stock logs
if (Schema::hasTable('stock_logs')) {
DB::table('stock_logs')->truncate();
DB::statement('ALTER TABLE stock_logs AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset stock_logs table');
}
// 2. Clear and reset stocks
if (Schema::hasTable('stocks')) {
DB::table('stocks')->truncate();
DB::statement('ALTER TABLE stocks AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset stocks table');
}
// 3. Clear and reset opname details
if (Schema::hasTable('opname_details')) {
DB::table('opname_details')->truncate();
DB::statement('ALTER TABLE opname_details AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset opname_details table');
}
// 4. Clear and reset opnames
if (Schema::hasTable('opnames')) {
DB::table('opnames')->truncate();
DB::statement('ALTER TABLE opnames AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset opnames table');
}
// Re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$this->info('Successfully cleared all data and reset IDs to 1!');
$this->info('Cleared tables:');
$this->info('- stock_logs');
$this->info('- stocks');
$this->info('- opname_details');
$this->info('- opnames');
Log::info('Complete data cleared and IDs reset by command', [
'user' => auth()->user() ? auth()->user()->id : 'system',
'timestamp' => now(),
'tables_cleared' => ['stock_logs', 'stocks', 'opname_details', 'opnames']
]);
} catch (\Exception $e) {
// Re-enable foreign key checks if they were disabled
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$this->error('Error clearing data: ' . $e->getMessage());
Log::error('Error in ClearOpnameData command: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString()
]);
return 1; // Return error code
}
return 0; // Return success code
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Privilege;
class SetupStockAuditMenu extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'setup:stock-audit-menu';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Setup Stock Audit menu and privileges';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Setting up Stock Audit menu...');
// Check if menu already exists
$existingMenu = Menu::where('link', 'stock-audit.index')->first();
if ($existingMenu) {
$this->warn('Stock Audit menu already exists!');
return 0;
}
// Create Stock Audit menu
$menu = Menu::create([
'name' => 'Audit Histori Stock',
'link' => 'stock-audit.index',
'created_at' => now(),
'updated_at' => now()
]);
$this->info('Stock Audit menu created with ID: ' . $menu->id);
// Give all roles access to this menu
$roles = Role::all();
$privilegeCount = 0;
foreach($roles as $role) {
// Check if privilege already exists
$existingPrivilege = Privilege::where('role_id', $role->id)
->where('menu_id', $menu->id)
->first();
if (!$existingPrivilege) {
Privilege::create([
'role_id' => $role->id,
'menu_id' => $menu->id,
'create' => 0, // Stock audit is view-only
'update' => 0, // Stock audit is view-only
'delete' => 0, // Stock audit is view-only
'view' => 1, // Allow viewing
'created_at' => now(),
'updated_at' => now()
]);
$privilegeCount++;
}
}
$this->info("Created {$privilegeCount} privileges for Stock Audit menu.");
$this->info('Stock Audit menu setup completed successfully!');
return 0;
}
}

4
app/Console/Kernel.php Normal file → Executable file
View File

@@ -28,5 +28,9 @@ class Kernel extends ConsoleKernel
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
$this->commands = [
Commands\ClearOpnameData::class,
];
}
}

59
app/Enums/MutationStatus.php Executable file
View File

@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
enum MutationStatus: string
{
case SENT = 'sent';
case RECEIVED = 'received';
case APPROVED = 'approved';
case REJECTED = 'rejected';
case CANCELLED = 'cancelled';
public function label(): string
{
return match($this) {
self::SENT => 'Terkirim ke Dealer',
self::RECEIVED => 'Diterima Dealer',
self::APPROVED => 'Disetujui & Stock Dipindahkan',
self::REJECTED => 'Ditolak',
self::CANCELLED => 'Dibatalkan',
};
}
public function color(): string
{
return match($this) {
self::SENT => 'primary',
self::RECEIVED => 'info',
self::APPROVED => 'brand',
self::REJECTED => 'danger',
self::CANCELLED => 'secondary',
};
}
public function textColorClass(): string
{
return match($this->color()) {
'success' => 'text-success',
'warning' => 'text-warning',
'danger' => 'text-danger',
'info' => 'text-info',
'primary' => 'text-primary',
'brand' => 'text-primary',
'secondary' => 'text-muted',
default => 'text-dark'
};
}
public static function getOptions(): array
{
return [
self::SENT->value => self::SENT->label(),
self::RECEIVED->value => self::RECEIVED->label(),
self::APPROVED->value => self::APPROVED->label(),
self::REJECTED->value => self::REJECTED->label(),
self::CANCELLED->value => self::CANCELLED->label(),
];
}
}

55
app/Enums/OpnameStatus.php Executable file
View File

@@ -0,0 +1,55 @@
<?php
namespace App\Enums;
enum OpnameStatus: string
{
case DRAFT = 'draft';
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match($this) {
self::DRAFT => 'Draft',
self::PENDING => 'Menunggu Persetujuan',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
public function color(): string
{
return match($this) {
self::DRAFT => 'warning',
self::PENDING => 'info',
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
public function textColorClass(): string
{
return match($this->color()) {
'success' => 'text-success',
'warning' => 'text-warning',
'danger' => 'text-danger',
'info' => 'text-info',
'primary' => 'text-primary',
'brand' => 'text-primary',
'secondary' => 'text-muted',
default => 'text-dark'
};
}
public static function getOptions(): array
{
return [
self::DRAFT->value => self::DRAFT->label(),
self::PENDING->value => self::PENDING->label(),
self::APPROVED->value => self::APPROVED->label(),
self::REJECTED->value => self::REJECTED->label(),
];
}
}

21
app/Enums/StockChangeType.php Executable file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum StockChangeType: string
{
case INCREASE = 'increase';
case DECREASE = 'decrease';
case ADJUSTMENT = 'adjustment'; // Untuk kasus dimana quantity sama tapi perlu dicatat
case NO_CHANGE = 'no_change'; // Untuk kasus dimana quantity sama dan tidak perlu dicatat
public function label(): string
{
return match($this) {
self::INCREASE => 'Penambahan',
self::DECREASE => 'Pengurangan',
self::ADJUSTMENT => 'Penyesuaian',
self::NO_CHANGE => 'Tidak Ada Perubahan'
};
}
}

0
app/Exceptions/Handler.php Normal file → Executable file
View File

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Exports;
use App\Models\Dealer;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class ProductStockDealers implements WithMultipleSheets
{
public function sheets(): array
{
$sheets = [];
$usedNames = [];
// Get all dealers with their stock data
$dealers = Dealer::with(['stocks.product.category'])->get();
/** @var Dealer $dealer */
foreach ($dealers as $dealer) {
$dealerSheet = new DealerStockSheet($dealer);
$sheetTitle = $dealerSheet->title();
// Handle duplicate sheet names
$originalTitle = $sheetTitle;
$counter = 1;
while (in_array($sheetTitle, $usedNames)) {
$sheetTitle = substr($originalTitle, 0, 28) . '_' . $counter;
$counter++;
}
$usedNames[] = $sheetTitle;
// Set the unique title
$dealerSheet->setUniqueTitle($sheetTitle);
$sheets[] = $dealerSheet;
}
return $sheets;
}
}
class DealerStockSheet implements FromCollection, WithTitle, WithHeadings, WithStyles, WithColumnWidths
{
protected $dealer;
protected $uniqueTitle;
public function __construct(Dealer $dealer)
{
$this->dealer = $dealer;
}
public function collection()
{
// Get all products with stock for this dealer
$stocks = $this->dealer->stocks()
->with(['product.category'])
->whereHas('product', function($query) {
$query->where('active', true);
})
->get();
$data = collect();
$no = 1;
foreach ($stocks as $stock) {
$product = $stock->product;
$data->push([
'no' => $no++,
'kode_produk' => $product->code,
'nama_produk' => $product->name,
'kategori' => $product->category ? $product->category->name : '-',
'satuan' => $product->unit ?? '-',
'stok' => number_format($stock->quantity, 2)
]);
}
// If no stock, add empty row
if ($data->isEmpty()) {
$data->push([
'no' => '-',
'kode_produk' => '-',
'nama_produk' => 'Tidak ada stok produk',
'kategori' => '-',
'satuan' => '-',
'stok' => '0'
]);
}
return $data;
}
public function setUniqueTitle(string $title): void
{
$this->uniqueTitle = $title;
}
public function title(): string
{
if (isset($this->uniqueTitle)) {
return $this->uniqueTitle;
}
// Clean dealer name for sheet title (remove invalid characters and handle edge cases)
$cleanName = $this->dealer->name;
// Remove parentheses and their contents
$cleanName = preg_replace('/\([^)]*\)/', '', $cleanName);
// Remove dots, commas, and other special characters
$cleanName = preg_replace('/[^A-Za-z0-9\-_ ]/', '', $cleanName);
// Clean up multiple spaces and trim
$cleanName = preg_replace('/\s+/', ' ', trim($cleanName));
// If name is empty after cleaning, use dealer ID
if (empty($cleanName)) {
$cleanName = 'Dealer_' . $this->dealer->id;
}
// Limit to 31 characters and ensure no leading/trailing spaces
$cleanName = trim(substr($cleanName, 0, 31));
// Ensure it doesn't end with a space (which can cause Excel issues)
return rtrim($cleanName);
}
public function headings(): array
{
return [
'No',
'Kode Produk',
'Nama Produk',
'Kategori',
'Satuan',
'Stok'
];
}
public function styles(Worksheet $sheet)
{
// Add dealer info at the top first
$sheet->insertNewRowBefore(1, 2);
$sheet->setCellValue('A1', 'STOK PRODUK DEALER: ' . strtoupper($this->dealer->name));
$sheet->setCellValue('A2', 'Tanggal Export: ' . now()->format('d/m/Y H:i:s'));
// Merge cells for dealer info
$sheet->mergeCells('A1:F1');
$sheet->mergeCells('A2:F2');
$lastRow = $sheet->getHighestRow();
// Style dealer info
$sheet->getStyle('A1:A2')->applyFromArray([
'font' => ['bold' => true, 'size' => 12],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER]
]);
// Style headers (row 3 after inserting 2 rows)
$sheet->getStyle('A3:F3')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
]);
// Style data rows if they exist
if ($lastRow > 3) {
$sheet->getStyle('A4:F' . $lastRow)->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CCCCCC']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER]
]);
// Center align specific columns
$sheet->getStyle('A4:A' . $lastRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('E4:F' . $lastRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
return $sheet;
}
public function columnWidths(): array
{
return [
'A' => 8, // No
'B' => 15, // Kode Produk
'C' => 30, // Nama Produk
'D' => 20, // Kategori
'E' => 12, // Satuan
'F' => 12 // Stok
];
}
}

0
app/Exports/TransactionDealerExport.php Normal file → Executable file
View File

0
app/Exports/TransactionExport.php Normal file → Executable file
View File

0
app/Exports/TransactionSaExport.php Normal file → Executable file
View File

0
app/Http/Controllers/AdminController.php Normal file → Executable file
View File

0
app/Http/Controllers/ApiController.php Normal file → Executable file
View File

View File

0
app/Http/Controllers/Auth/ForgotPasswordController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/LoginController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/RegisterController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/ResetPasswordController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/VerificationController.php Normal file → Executable file
View File

12
app/Http/Controllers/CategoryController.php Normal file → Executable file
View File

@@ -25,16 +25,16 @@ class CategoryController extends Controller
$data = Category::all();
return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) {
$btn = '';
$btn = '<div class="d-flex">';
if(Auth::user()->can('delete', $menu)) {
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editCategory'. $row->id .'" data-url="'. route('category.edit', $row->id) .'" data-action="'. route('category.update', $row->id) .'" onclick="editCategory('. $row->id .')"> Edit </button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('category.destroy', $row->id) .'" id="destroyCategory'. $row->id .'" onclick="destroyCategory('. $row->id .')"> Hapus </button>';
}
if(Auth::user()->can('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editCategory'. $row->id .'" data-url="'. route('category.edit', $row->id) .'" data-action="'. route('category.update', $row->id) .'" onclick="editCategory('. $row->id .')"> Edit </button>';
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])

0
app/Http/Controllers/Controller.php Normal file → Executable file
View File

17
app/Http/Controllers/DealerController.php Normal file → Executable file
View File

@@ -27,27 +27,28 @@ class DealerController extends Controller
$data = Dealer::leftJoin('users as u', 'u.id', '=', 'pic')->select('u.name as pic_name', 'dealers.*');
return Datatables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) {
$btn = '';
$btn = '<div class="d-flex">';
if($row->pic != null) {
if(Auth::user()->can('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
}
if(Auth::user()->can('update', $menu)) {
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>';
}
}else{
if(Auth::user()->can('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
}
if(Auth::user()->can('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>
<button class="btn btn-success btn-sm btn-bold" data-action="'. route('dealer.picstore', $row->id) .'" id="addPic'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" onclick="addPic('. $row->id .')"> Tambahkan PIC </button>';
}
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])

0
app/Http/Controllers/HomeController.php Normal file → Executable file
View File

17
app/Http/Controllers/ReportController.php Normal file → Executable file
View File

@@ -440,23 +440,26 @@ class ReportController extends Controller
$data->orderBy('date', 'DESC');
return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) {
$btn = '';
$btn = '<div class="d-flex justify-content-center">';
if($row->status == 1) {
if(Auth::user()->can('delete', $menu)) {
$btn .= ' <button class="btn btn-danger btn-sm btn-bold" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>';
if(Gate::allows('delete', $menu)) {
$btn .= ' <button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>';
}
$btn .= '<span class="badge badge-success">Closed</span>';
}else{
if(Auth::user()->can('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>';
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>';
}
if(Auth::user()->can('update', $menu)) {
$btn .= '<button class="btn btn-info btn-sm btn-bold" data-url="'. route('report.transaction.edit', $row->id) .'" data-action="'. route('report.transaction.update', $row->id) .'" onclick="editTransaction('. $row->id .')" id="editTransaction'. $row->id .'"> Edit </button>
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-info btn-sm btn-bold mr-2" data-url="'. route('report.transaction.edit', $row->id) .'" data-action="'. route('report.transaction.update', $row->id) .'" onclick="editTransaction('. $row->id .')" id="editTransaction'. $row->id .'"> Edit </button>
<button class="btn btn-warning btn-sm btn-bold" id="closeTransaction'. $row->id .'" data-url="'. route('report.transaction.close', $row->id) .'" onclick="closeTransaction('. $row->id .')"> Close </button>';
}
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])

0
app/Http/Controllers/RolePrivilegeController.php Normal file → Executable file
View File

333
app/Http/Controllers/TransactionController.php Normal file → Executable file
View File

@@ -4,20 +4,35 @@ namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Dealer;
use App\Models\Product;
use App\Models\Stock;
use App\Models\Transaction;
use App\Models\User;
use App\Models\Work;
use App\Services\StockService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Exception;
class TransactionController extends Controller
{
protected $stockService;
public function __construct(StockService $stockService)
{
$this->stockService = $stockService;
}
public function index()
{
$work_works = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id')->select('c.name as category_name', 'works.*')->where('c.name', 'LIKE', '%kerja%')->get();
$work_works = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id')
->select('c.name as category_name', 'works.*')
->where('c.name', 'LIKE', '%kerja%')
->orderBy('works.name', 'asc')
->get();
$wash_work = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id')->select('c.name as category_name', 'works.*')->where('c.name', 'LIKE', '%cuci%')->first();
$user_sas = User::where('role_id', 4)->where('dealer_id', Auth::user()->dealer_id)->get();
$count_transaction_users = Transaction::where("user_id", Auth::user()->id)->count();
@@ -26,12 +41,22 @@ class TransactionController extends Controller
->select('d.name as dealer_name', 'd.id as dealer_id', 'users.name', 'users.id', 'users.role', 'users.email', 'd.dealer_code', 'd.address')
->where('users.id', Auth::user()->id)->first();
$now = Carbon::now()->translatedFormat('d F Y');
return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic'));
// Get products with stock based on user role
$products = Product::with(['stocks' => function($query) {
$query->where('dealer_id', Auth::user()->dealer_id);
}, 'stocks.dealer'])
->where('active', true)
->get();
return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic', 'products'));
}
public function workcategory($category_id)
{
$works = Work::where('category_id', $category_id)->get();
$works = Work::where('category_id', $category_id)
->orderBy('name', 'asc')
->get();
$response = [
"message" => "get work category successfully",
"data" => $works,
@@ -619,25 +644,105 @@ class TransactionController extends Controller
public function destroy($id)
{
Transaction::find($id)->delete();
$response = [
'message' => 'Data deleted successfully',
'status' => 200
];
return redirect()->back();
DB::beginTransaction();
try {
$transaction = Transaction::find($id);
if (!$transaction) {
return redirect()->back()->withErrors(['error' => 'Transaksi tidak ditemukan']);
}
// Restore stock before deleting transaction
$this->stockService->restoreStockForTransaction($transaction);
// Delete the transaction
$transaction->delete();
DB::commit();
return redirect()->back()->with('success', 'Transaksi berhasil dihapus dan stock telah dikembalikan');
} catch (Exception $e) {
DB::rollback();
return redirect()->back()->withErrors(['error' => 'Gagal menghapus transaksi: ' . $e->getMessage()]);
}
}
public function store(Request $request)
{
$request['quantity'] = array_filter($request['quantity'], function($value) { return !is_null($value) && $value !== ''; });
// Handle different form types (work vs wash)
$isWashForm = $request->form === 'wash';
$validWorkIds = [];
$validQuantities = [];
$validPairs = [];
if ($isWashForm) {
// For wash form, work_id and quantity are already fixed
$validWorkIds = $request->work_id;
$validQuantities = $request->quantity;
// Create pairs for wash form
if (is_array($request->work_id) && is_array($request->quantity)) {
for ($i = 0; $i < count($request->work_id); $i++) {
$validPairs[] = [
'work_id' => $request->work_id[$i],
'quantity' => $request->quantity[$i],
'index' => $i
];
}
}
} else {
// For work form, filter out empty work/quantity pairs before validation
if ($request->work_id && $request->quantity) {
for ($i = 0; $i < count($request->work_id); $i++) {
$workId = $request->work_id[$i] ?? null;
$quantity = $request->quantity[$i] ?? null;
// Only include pairs where both work_id and quantity are filled
if (!empty($workId) && !empty($quantity) && $quantity > 0) {
$validWorkIds[] = $workId;
$validQuantities[] = $quantity;
$validPairs[] = [
'work_id' => $workId,
'quantity' => $quantity,
'index' => $i
];
}
}
}
// Check if at least one valid pair exists (only for work form)
if (empty($validPairs)) {
return redirect()->back()
->withErrors(['error' => 'Minimal pilih satu pekerjaan dan isi quantity-nya'])
->withInput();
}
}
// Update request with filtered data for validation
$request->merge([
'work_id' => $validWorkIds,
'quantity' => $validQuantities
]);
$request->validate([
'work_id.*' => ['required', 'integer'],
'quantity.*' => ['required', 'integer'],
'spk_no' => ['required', function($attribute, $value, $fail) use($request) {
$date = explode('/', $request->date);
$date = $date[2].'-'.$date[0].'-'.$date[1];
'work_id.*' => ['required', 'integer', 'exists:works,id'],
'quantity.*' => ['required', 'integer', 'min:1'],
'spk_no' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) {
// Handle date format conversion safely for validation
if (strpos($request->date, '/') !== false) {
$dateParts = explode('/', $request->date);
if (count($dateParts) === 3) {
$date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1];
} else {
$fail('Format tanggal tidak valid');
return;
}
} else {
$date = $request->date;
}
if(!$request->work_id) {
$fail('Pekerjaan harus diisi');
@@ -655,9 +760,19 @@ class TransactionController extends Controller
}
}
}],
'police_number' => ['required', function($attribute, $value, $fail) use($request) {
$date = explode('/', $request->date);
$date = $date[2].'-'.$date[0].'-'.$date[1];
'police_number' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) {
// Handle date format conversion safely for validation
if (strpos($request->date, '/') !== false) {
$dateParts = explode('/', $request->date);
if (count($dateParts) === 3) {
$date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1];
} else {
$fail('Format tanggal tidak valid');
return;
}
} else {
$date = $request->date;
}
if(!$request->work_id) {
$fail('Pekerjaan harus diisi');
@@ -675,10 +790,20 @@ class TransactionController extends Controller
}
}
}],
'warranty' => ['required'],
'date' => ['required', function($attribute, $value, $fail) use($request) {
$date = explode('/', $value);
$date = $date[2].'-'.$date[0].'-'.$date[1];
'warranty' => ['required', 'in:0,1'],
'date' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) {
// Handle date format conversion safely for validation
if (strpos($value, '/') !== false) {
$dateParts = explode('/', $value);
if (count($dateParts) === 3) {
$date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1];
} else {
$fail('Format tanggal tidak valid. Gunakan format MM/DD/YYYY atau YYYY-MM-DD');
return;
}
} else {
$date = $value;
}
if(!$request->work_id) {
$fail('Pekerjaan harus diisi');
@@ -697,31 +822,91 @@ class TransactionController extends Controller
}
}],
'category' => ['required'],
'user_sa_id' => ['required', 'integer'],
'user_sa_id' => ['required', 'integer', 'exists:users,id'],
], [
'spk_no.required' => 'No. SPK harus diisi',
'spk_no.min' => 'No. SPK tidak boleh kosong',
'police_number.required' => 'No. Polisi harus diisi',
'police_number.min' => 'No. Polisi tidak boleh kosong',
'date.required' => 'Tanggal Pekerjaan harus diisi',
'date.min' => 'Tanggal Pekerjaan tidak boleh kosong',
'warranty.required' => 'Warranty harus dipilih',
'user_sa_id.required' => 'Service Advisor harus dipilih',
'user_sa_id.exists' => 'Service Advisor yang dipilih tidak valid',
'work_id.*.required' => 'Pekerjaan yang dipilih harus valid',
'work_id.*.exists' => 'Pekerjaan yang dipilih tidak ditemukan',
'quantity.*.required' => 'Quantity harus diisi untuk setiap pekerjaan yang dipilih',
'quantity.*.min' => 'Quantity minimal 1',
]);
$request['date'] = explode('/', $request->date);
$request['date'] = $request['date'][2].'-'.$request['date'][0].'-'.$request['date'][1];
$data = [];
for($i = 0; $i < count($request->work_id); $i++) {
$data[] = [
"user_id" => $request->mechanic_id,
"dealer_id" => $request->dealer_id,
"form" => $request->form,
"work_id" => $request->work_id[$i],
"qty" => $request->quantity[$i],
"spk" => $request->spk_no,
"police_number" => $request->police_number,
"warranty" => $request->warranty,
"user_sa_id" => $request->user_sa_id,
"date" => $request->date,
"created_at" => date('Y-m-d H:i:s')
];
// Handle date format conversion safely
$dateValue = $request->date;
if (strpos($dateValue, '/') !== false) {
// If date is in MM/DD/YYYY format, convert to Y-m-d
$dateParts = explode('/', $dateValue);
if (count($dateParts) === 3) {
$request['date'] = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1];
} else {
// Invalid date format, use as is
$request['date'] = $dateValue;
}
} else {
// Date is already in Y-m-d format or other format, use as is
$request['date'] = $dateValue;
}
Transaction::insert($data);
return redirect()->back()->with('success', 'Berhasil input pekerjaan');
// Stock checking removed - allow negative stock
DB::beginTransaction();
try {
$transactions = [];
$data = [];
// Create transaction records using filtered valid pairs
foreach($validPairs as $pair) {
$transactionData = [
"user_id" => $request->mechanic_id,
"dealer_id" => $request->dealer_id,
"form" => $request->form,
"work_id" => $pair['work_id'],
"qty" => $pair['quantity'],
"spk" => $request->spk_no,
"police_number" => $request->police_number,
"warranty" => $request->warranty,
"user_sa_id" => $request->user_sa_id,
"date" => $request->date,
"status" => 'completed', // Mark as completed to trigger stock reduction
"created_at" => date('Y-m-d H:i:s'),
"updated_at" => date('Y-m-d H:i:s')
];
$data[] = $transactionData;
}
// Insert all transactions
Transaction::insert($data);
// Get the created transactions for stock reduction
$createdTransactions = Transaction::where('spk', $request->spk_no)
->where('police_number', $request->police_number)
->where('date', $request->date)
->where('dealer_id', $request->dealer_id)
->get();
// Reduce stock for each transaction
foreach ($createdTransactions as $transaction) {
$this->stockService->reduceStockForTransaction($transaction);
}
DB::commit();
return redirect()->back()->with('success', 'Berhasil input pekerjaan dan stock telah dikurangi otomatis');
} catch (Exception $e) {
DB::rollback();
return redirect()->back()
->withErrors(['error' => 'Gagal menyimpan transaksi: ' . $e->getMessage()])
->withInput();
}
}
public function edit($id)
@@ -754,4 +939,62 @@ class TransactionController extends Controller
return response()->json($response);
}
/**
* Check stock availability for work at dealer
*/
public function checkStockAvailability(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
'quantity' => 'required|integer|min:1'
]);
try {
$availability = $this->stockService->checkStockAvailability(
$request->work_id,
$request->dealer_id,
$request->quantity
);
return response()->json([
'status' => 200,
'data' => $availability
]);
} catch (Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Error checking stock: ' . $e->getMessage()
], 500);
}
}
/**
* Get stock prediction for work
*/
public function getStockPrediction(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'quantity' => 'required|integer|min:1'
]);
try {
$prediction = $this->stockService->getStockUsagePrediction(
$request->work_id,
$request->quantity
);
return response()->json([
'status' => 200,
'data' => $prediction
]);
} catch (Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Error getting prediction: ' . $e->getMessage()
], 500);
}
}
}

12
app/Http/Controllers/UserController.php Normal file → Executable file
View File

@@ -24,16 +24,16 @@ class UserController extends Controller
return DataTables::of($data)
->addIndexColumn()
->addColumn('action', function($row) use ($menu) {
$btn = '';
$btn = '<div class="d-flex">';
if(Auth::user()->can('delete', $menu)) {
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editUser'. $row->id .'" data-url="'. route('user.edit', $row->id) .'" data-action="'. route('user.update', $row->id) .'" onclick="editUser('. $row->id .')"> Edit </button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('user.destroy', $row->id) .'" id="destroyUser'. $row->id .'" onclick="destroyUser('. $row->id .')"> Hapus </button>';
}
if(Auth::user()->can('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editUser'. $row->id .'" data-url="'. route('user.edit', $row->id) .'" data-action="'. route('user.update', $row->id) .'" onclick="editUser('. $row->id .')"> Edit </button>';
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])

View File

@@ -0,0 +1,553 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Mutation;
use App\Models\MutationDetail;
use App\Models\Product;
use App\Models\Dealer;
use App\Enums\MutationStatus;
use App\Models\Menu;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
use Illuminate\Support\Facades\Gate;
class MutationsController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link','mutations.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
if ($request->ajax()) {
// Use a more specific query to avoid join conflicts
$data = Mutation::query()
->with(['fromDealer', 'toDealer', 'requestedBy.role', 'approvedBy.role', 'receivedBy.role'])
->select(['mutations.*']);
// Filter berdasarkan dealer jika user bukan admin
if (auth()->user()->dealer_id) {
$data->where(function($query) {
$query->where('from_dealer_id', auth()->user()->dealer_id)
->orWhere('to_dealer_id', auth()->user()->dealer_id);
});
}
// Filter berdasarkan dealer yang dipilih
if ($request->filled('dealer_filter')) {
$data->where(function($query) use ($request) {
$query->where('from_dealer_id', $request->dealer_filter)
->orWhere('to_dealer_id', $request->dealer_filter);
});
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
try {
$dateFrom = \Carbon\Carbon::parse($request->date_from)->format('Y-m-d');
$data->whereDate('mutations.created_at', '>=', $dateFrom);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('mutations.created_at', '>=', $request->date_from);
}
}
if ($request->filled('date_to')) {
try {
$dateTo = \Carbon\Carbon::parse($request->date_to)->format('Y-m-d');
$data->whereDate('mutations.created_at', '<=', $dateTo);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('mutations.created_at', '<=', $request->date_to);
}
}
return DataTables::of($data)
->addIndexColumn()
->addColumn('mutation_number', function($row) {
return $row->mutation_number;
})
->addColumn('from_dealer', function($row) {
return $row->fromDealer->name ?? '-';
})
->addColumn('to_dealer', function($row) {
return $row->toDealer->name ?? '-';
})
->addColumn('requested_by', function($row) {
return $row->requestedBy->name ?? '-';
})
->addColumn('status', function($row) {
$status = $row->status instanceof MutationStatus ? $row->status : MutationStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('total_items', function($row) {
return number_format($row->total_items, 0);
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) {
return view('warehouse_management.mutations._action', compact('row'))->render();
})
// Enhanced filtering
->filterColumn('mutation_number', function($query, $keyword) {
$query->where('mutations.mutation_number', 'like', "%{$keyword}%");
})
->filterColumn('from_dealer', function($query, $keyword) {
$query->whereHas('fromDealer', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('to_dealer', function($query, $keyword) {
$query->whereHas('toDealer', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('requested_by', function($query, $keyword) {
$query->whereHas('requestedBy', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('status', function($query, $keyword) {
$query->where('mutations.status', 'like', "%{$keyword}%");
})
->filterColumn('created_at', function($query, $keyword) {
$query->whereDate('mutations.created_at', 'like', "%{$keyword}%");
})
// Enhanced ordering - avoid join conflicts by using subqueries
->orderColumn('mutation_number', function($query, $order) {
$query->orderBy('mutations.mutation_number', $order);
})
->orderColumn('from_dealer', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM dealers WHERE dealers.id = mutations.from_dealer_id)'),
$order
);
})
->orderColumn('to_dealer', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM dealers WHERE dealers.id = mutations.to_dealer_id)'),
$order
);
})
->orderColumn('requested_by', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM users WHERE users.id = mutations.requested_by)'),
$order
);
})
->orderColumn('total_items', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT SUM(quantity_requested) FROM mutation_details WHERE mutation_details.mutation_id = mutations.id)'),
$order
);
})
->orderColumn('status', function($query, $order) {
$query->orderBy('mutations.status', $order);
})
->orderColumn('created_at', function($query, $order) {
$query->orderBy('mutations.created_at', $order);
})
->rawColumns(['status', 'action'])
->make(true);
}
return view('warehouse_management.mutations.index', compact('menu', 'dealers'));
}
public function create()
{
$menu = Menu::where('link','mutations.create')->first();
$dealers = Dealer::all();
$products = Product::with('stocks')->get();
return view('warehouse_management.mutations.create', compact('menu', 'dealers', 'products'));
}
public function store(Request $request)
{
$request->validate([
'from_dealer_id' => 'required|exists:dealers,id',
'to_dealer_id' => 'required|exists:dealers,id|different:from_dealer_id',
'shipping_notes' => 'nullable|string',
'products' => 'required|array|min:1',
'products.*.product_id' => 'required|exists:products,id',
'products.*.quantity_requested' => 'required|numeric|min:0.01'
]);
DB::beginTransaction();
try {
// Buat mutation record dengan status SENT (langsung terkirim ke dealer tujuan)
$mutation = Mutation::create([
'from_dealer_id' => $request->from_dealer_id,
'to_dealer_id' => $request->to_dealer_id,
'status' => MutationStatus::SENT,
'requested_by' => auth()->id(),
'shipping_notes' => $request->shipping_notes
]);
// Buat mutation details
foreach ($request->products as $productData) {
MutationDetail::create([
'mutation_id' => $mutation->id,
'product_id' => $productData['product_id'],
'quantity_requested' => $productData['quantity_requested']
]);
}
DB::commit();
// Check if request came from transaction page
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->back()
->with('success', 'Mutasi berhasil dibuat dan terkirim ke dealer tujuan');
}
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil dibuat dan terkirim ke dealer tujuan');
} catch (\Exception $e) {
DB::rollback();
return back()->withErrors(['error' => 'Gagal membuat mutasi: ' . $e->getMessage()]);
}
}
public function show(Mutation $mutation)
{
$mutation->load([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product'
]);
return view('warehouse_management.mutations.show', compact('mutation'));
}
public function receive(Request $request, Mutation $mutation)
{
$request->validate([
'reception_notes' => 'nullable|string',
'products' => 'required|array',
'products.*.quantity_approved' => 'required|numeric|min:0',
'products.*.notes' => 'nullable|string'
]);
if (!$mutation->canBeReceived()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat diterima dalam status saat ini']);
}
DB::beginTransaction();
try {
// Update product details dengan quantity_approved dan notes
if ($request->products) {
foreach ($request->products as $detailId => $productData) {
$updateData = [];
// Set quantity_approved
if (isset($productData['quantity_approved'])) {
$updateData['quantity_approved'] = $productData['quantity_approved'];
}
// Set notes jika ada
if (isset($productData['notes']) && !empty($productData['notes'])) {
$updateData['notes'] = $productData['notes'];
}
if (!empty($updateData)) {
MutationDetail::where('id', $detailId)
->where('mutation_id', $mutation->id)
->update($updateData);
}
}
}
// Receive mutation with reception notes
$mutation->receive(auth()->id(), $request->reception_notes);
DB::commit();
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Users without dealer_id are likely admin, redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil diterima dan siap untuk disetujui. Stock akan dipindahkan setelah disetujui.');
} else {
// Dealer users redirect back to transaction page
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil diterima. Silakan setujui mutasi ini untuk memindahkan stock.')
->with('active_tab', 'penerimaan');
}
} catch (\Exception $e) {
DB::rollback();
return back()->withErrors(['error' => 'Gagal menerima mutasi: ' . $e->getMessage()]);
}
}
public function approve(Request $request, Mutation $mutation)
{
$request->validate([
'approval_notes' => 'nullable|string'
]);
if (!$mutation->canBeApproved()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat disetujui dalam status saat ini']);
}
try {
// Approve mutation (stock will move automatically)
$mutation->approve(auth()->id(), $request->approval_notes);
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Admin users redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan');
} else {
// Dealer users
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan')
->with('active_tab', 'penerimaan');
} else {
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan');
}
}
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal menyetujui mutasi: ' . $e->getMessage()]);
}
}
public function reject(Request $request, Mutation $mutation)
{
$request->validate([
'rejection_reason' => 'required|string'
]);
if (!$mutation->canBeApproved()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat ditolak dalam status saat ini']);
}
try {
$mutation->reject(auth()->id(), $request->rejection_reason);
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Admin users redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil ditolak');
} else {
// Dealer users
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil ditolak')
->with('active_tab', 'penerimaan');
} else {
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil ditolak');
}
}
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal menolak mutasi: ' . $e->getMessage()]);
}
}
// Complete method removed - Stock moves automatically after approval
public function cancel(Request $request, Mutation $mutation)
{
$request->validate([
'cancellation_reason' => 'nullable|string'
]);
if (!$mutation->canBeCancelled()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat dibatalkan dalam status saat ini']);
}
try {
$mutation->cancel(auth()->id(), $request->cancellation_reason);
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil dibatalkan');
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal membatalkan mutasi: ' . $e->getMessage()]);
}
}
// API untuk mendapatkan stock produk di dealer tertentu
public function getProductStock(Request $request)
{
$dealerId = $request->dealer_id;
$productId = $request->product_id;
$product = Product::findOrFail($productId);
$stock = $product->getStockByDealer($dealerId);
return response()->json([
'product_name' => $product->name,
'current_stock' => $stock
]);
}
// API untuk mendapatkan mutasi yang perlu diterima oleh dealer
public function getPendingMutations(Request $request)
{
$dealerId = $request->dealer_id;
// Get mutations that need action from this dealer:
// 1. 'sent' status where this dealer is the recipient (need to receive)
// 2. 'received' status where this dealer is the recipient (show as waiting for admin approval)
// 3. 'approved' status where this dealer is the recipient (show as completed)
// 4. 'rejected' status where this dealer is the recipient (show as rejected)
$data = Mutation::with(['fromDealer', 'toDealer', 'requestedBy.role'])
->where(function($query) use ($dealerId) {
// Mutations sent to this dealer that need to be received
$query->where('to_dealer_id', $dealerId)
->where('status', 'sent');
// OR mutations received by this dealer (waiting for admin approval)
$query->orWhere(function($subQuery) use ($dealerId) {
$subQuery->where('to_dealer_id', $dealerId)
->where('status', 'received');
});
// OR mutations approved/rejected for this dealer (historical data)
$query->orWhere(function($subQuery) use ($dealerId) {
$subQuery->where('to_dealer_id', $dealerId)
->whereIn('status', ['approved', 'rejected']);
});
})
->orderBy('mutations.id', 'desc'); // Default order by ID desc
return DataTables::of($data)
->addIndexColumn()
->addColumn('mutation_number', function($row) {
return $row->mutation_number;
})
->addColumn('from_dealer', function($row) {
return $row->fromDealer->name ?? '-';
})
->addColumn('to_dealer', function($row) {
return $row->toDealer->name ?? '-';
})
->addColumn('status', function($row) {
$status = $row->status instanceof MutationStatus ? $row->status : MutationStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('total_items', function($row) {
return number_format($row->total_items, 0);
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) use ($dealerId) {
$buttons = '';
if ($row->status->value === 'sent' && $row->to_dealer_id == $dealerId) {
// For sent mutations where current dealer is recipient - show detail button for receiving
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail & Terima
</button>';
} elseif ($row->status->value === 'received' && $row->to_dealer_id == $dealerId) {
// For received mutations where current dealer is recipient - only show detail (approval is admin only)
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-muted">Menunggu persetujuan admin</small></div>';
} elseif ($row->status->value === 'approved' && $row->to_dealer_id == $dealerId) {
// For approved mutations - show detail only
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-success">Disetujui admin</small></div>';
} elseif ($row->status->value === 'rejected' && $row->to_dealer_id == $dealerId) {
// For rejected mutations - show detail only
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-danger">Ditolak admin</small></div>';
}
return $buttons;
})
->rawColumns(['status', 'action'])
->make(true);
}
// API untuk mendapatkan detail mutasi
public function getDetail(Mutation $mutation)
{
try {
$mutation->load([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product'
]);
// Format created_at
$mutation->created_at_formatted = $mutation->created_at->format('d M Y, H:i');
// Add status color and label
$mutation->status_color = $mutation->status_color;
$mutation->status_label = $mutation->status_label;
// Check if can be received
$mutation->can_be_received = $mutation->canBeReceived();
return response()->json([
'success' => true,
'data' => $mutation
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal memuat detail mutasi: ' . $e->getMessage()
], 500);
}
}
public function print($id)
{
try {
$mutation = Mutation::with([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product.category'
])->findOrFail($id);
return view('warehouse_management.mutations.print', compact('mutation'));
} catch (\Exception $e) {
Log::error('Error printing mutation: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print mutasi.');
}
}
}

View File

@@ -0,0 +1,497 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Enums\OpnameStatus;
use App\Http\Controllers\Controller;
use App\Models\Dealer;
use App\Models\Menu;
use App\Models\Opname;
use App\Models\OpnameDetail;
use App\Models\Product;
use App\Models\Stock;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Support\Facades\Gate;
class OpnamesController extends Controller
{
public function index(Request $request){
$menu = Menu::where('link','opnames.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
if($request->ajax()){
$data = Opname::query()
->with('user','dealer')
->orderBy('created_at', 'desc');
// Filter berdasarkan dealer yang dipilih
if ($request->filled('dealer_filter')) {
$data->where('dealer_id', $request->dealer_filter);
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
try {
$dateFrom = \Carbon\Carbon::parse($request->date_from)->format('Y-m-d');
$data->whereDate('opname_date', '>=', $dateFrom);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('opname_date', '>=', $request->date_from);
}
}
if ($request->filled('date_to')) {
try {
$dateTo = \Carbon\Carbon::parse($request->date_to)->format('Y-m-d');
$data->whereDate('opname_date', '<=', $dateTo);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('opname_date', '<=', $request->date_to);
}
}
return DataTables::of($data)
->addColumn('user_name', function ($row){
return $row->user ? $row->user->name : '-';
})
->addColumn('dealer_name', function ($row){
return $row->dealer ? $row->dealer->name : '-';
})
->editColumn('opname_date', function ($row){
return $row->opname_date ? Carbon::parse($row->opname_date)->format('d M Y') : '-';
})
->editColumn('created_at', function ($row) {
return Carbon::parse($row->created_at)->format('d M Y H:i');
})
->editColumn('status', function ($row) {
$status = $row->status instanceof OpnameStatus ? $row->status : OpnameStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('action', function ($row) use ($menu) {
$btn = '<div class="d-flex">';
$btn .= '<a href="'.route('opnames.show', $row->id).'" class="btn btn-primary btn-sm" style="margin-right: 8px;">Detail</a>';
$btn .= '<a href="'.route('opnames.print', $row->id).'" class="btn btn-success btn-sm" target="_blank">Print</a>';
$btn .= '</div>';
return $btn;
})
->rawColumns(['action', 'status'])
->make(true);
}
return view('warehouse_management.opnames.index', compact('dealers'));
}
public function create(){
try{
$dealers = Dealer::all();
$products = Product::where('active', true)->get();
// Get initial stock data for the first dealer (if any)
$initialDealerId = $dealers->first()?->id;
$stocks = [];
if ($initialDealerId) {
$stocks = Stock::where('dealer_id', $initialDealerId)
->whereIn('product_id', $products->pluck('id'))
->get()
->keyBy('product_id');
}
return view('warehouse_management.opnames.create', compact('dealers', 'products', 'stocks'));
} catch(\Exception $ex) {
Log::error($ex->getMessage());
return back()->with('error', 'Terjadi kesalahan saat memuat data');
}
}
public function store(Request $request)
{
try {
DB::beginTransaction();
// Check if this is from transaction form or regular opname form
$isTransactionForm = $request->has('form') && $request->form === 'opname';
if ($isTransactionForm) {
// Custom validation for transaction form
$request->validate([
'dealer_id' => 'required|exists:dealers,id',
'user_id' => 'required|exists:users,id',
'opname_date' => [
'nullable',
'string',
'date_format:Y-m-d',
'before_or_equal:today'
],
'description' => 'nullable|string|max:1000',
'product_id' => 'required|array|min:1',
'product_id.*' => 'required|exists:products,id',
'system_stock' => 'required|array',
'system_stock.*' => 'required|numeric|min:0',
'physical_stock' => 'required|array',
'physical_stock.*' => 'required|numeric|min:0'
]);
// Process transaction form data with proper date parsing
$dealerId = $request->dealer_id;
$userId = $request->user_id;
// Parse opname date (YYYY-MM-DD format) or use today if empty
$inputDate = $request->opname_date ?: now()->format('Y-m-d');
Log::info('Parsing opname date', ['input' => $request->opname_date, 'using' => $inputDate]);
$opnameDate = Carbon::createFromFormat('Y-m-d', $inputDate);
Log::info('Successfully parsed opname date', ['parsed' => $opnameDate->format('Y-m-d')]);
$note = $request->description;
$productIds = $request->product_id;
$systemStocks = $request->system_stock;
$physicalStocks = $request->physical_stock;
// Log input data untuk debugging
Log::info('Transaction form input data', [
'product_ids' => $productIds,
'system_stocks' => $systemStocks,
'physical_stocks' => $physicalStocks,
'dealer_id' => $dealerId,
'user_id' => $userId
]);
} else {
// Original validation for regular opname form
$request->validate([
'dealer' => 'required|exists:dealers,id',
'product' => 'required|array|min:1',
'product.*' => 'required|exists:products,id',
'system_quantity' => 'required|array',
'system_quantity.*' => 'required|numeric|min:0',
'physical_quantity' => 'required|array',
'physical_quantity.*' => 'required|numeric|min:0',
'note' => 'nullable|string|max:1000',
'item_notes' => 'nullable|array',
'item_notes.*' => 'nullable|string|max:255',
'opname_date' => 'nullable|date' // Add opname_date validation for regular form
]);
// Process regular form data
$dealerId = $request->dealer;
$userId = auth()->id();
// Use provided date or current date
$inputDate = $request->opname_date ?: now()->format('Y-m-d');
$opnameDate = $request->opname_date ?
Carbon::createFromFormat('Y-m-d', $inputDate) :
now();
$note = $request->note;
$productIds = $request->product;
$systemStocks = $request->system_quantity;
$physicalStocks = $request->physical_quantity;
}
// 2. Validasi minimal ada produk yang diisi (termasuk nilai 0)
$validProductIds = array_filter($productIds);
$validSystemStocks = array_filter($systemStocks, function($value) { return $value !== null && $value !== ''; });
$validPhysicalStocks = array_filter($physicalStocks, function($value) {
return $value !== null && $value !== '' && is_numeric($value);
});
if (empty($validProductIds) || count($validProductIds) === 0) {
throw new \Exception('Minimal harus ada satu produk yang diisi untuk opname.');
}
if (count($validPhysicalStocks) === 0) {
throw new \Exception('Minimal harus ada satu stock fisik yang diisi (termasuk nilai 0).');
}
// 3. Validasi duplikasi produk
$productCounts = array_count_values($validProductIds);
foreach ($productCounts as $productId => $count) {
if ($count > 1) {
throw new \Exception('Produk tidak boleh duplikat dalam satu opname.');
}
}
// 4. Validasi dealer
$dealer = Dealer::findOrFail($dealerId);
// 5. Validasi user exists
$user = User::findOrFail($userId);
// 6. Validasi produk aktif
$filteredProductIds = array_filter($productIds);
$inactiveProducts = Product::whereIn('id', $filteredProductIds)
->where('active', false)
->pluck('name')
->toArray();
if (!empty($inactiveProducts)) {
throw new \Exception('Produk berikut tidak aktif: ' . implode(', ', $inactiveProducts));
}
// 7. Validasi stock difference (for transaction form, we'll allow any difference without note requirement)
$stockDifferences = [];
if (!$isTransactionForm) {
// Only validate notes for regular opname form
foreach ($productIds as $index => $productId) {
if (!$productId) continue;
$systemStock = floatval($systemStocks[$index] ?? 0);
$physicalStock = floatval($physicalStocks[$index] ?? 0);
$itemNote = $request->input("item_notes.{$index}");
// Jika ada perbedaan stock dan note kosong
if (abs($systemStock - $physicalStock) > 0.01 && empty($itemNote)) {
$product = Product::find($productId);
$stockDifferences[] = $product->name;
}
}
if (!empty($stockDifferences)) {
throw new \Exception(
'Catatan harus diisi untuk produk berikut karena ada perbedaan stock: ' .
implode(', ', $stockDifferences)
);
}
}
// 8. Create Opname master record with approved status
$opname = Opname::create([
'dealer_id' => $dealerId,
'opname_date' => $opnameDate,
'user_id' => $userId,
'note' => $note,
'status' => OpnameStatus::APPROVED, // Set status langsung approved
'approved_by' => $userId, // Set current user sebagai approver
'approved_at' => now() // Set waktu approval
]);
// 9. Create OpnameDetails and update stock - only for valid entries
$details = [];
$processedCount = 0;
foreach ($productIds as $index => $productId) {
if (!$productId) continue;
// Skip only if physical stock is truly not provided (empty string or null)
// Accept 0 as valid input
if (!isset($physicalStocks[$index]) || $physicalStocks[$index] === '' || $physicalStocks[$index] === null) {
continue;
}
// Validate that physical stock is numeric (including 0)
if (!is_numeric($physicalStocks[$index])) {
continue;
}
$systemStock = floatval($systemStocks[$index] ?? 0);
$physicalStock = floatval($physicalStocks[$index]);
$difference = $physicalStock - $systemStock;
$processedCount++;
// Get item note (only for regular opname form)
$itemNote = null;
if (!$isTransactionForm) {
$itemNote = $request->input("item_notes.{$index}");
}
// Create opname detail
$details[] = [
'opname_id' => $opname->id,
'product_id' => $productId,
'system_stock' => $systemStock,
'physical_stock' => $physicalStock,
'difference' => $difference,
'note' => $itemNote,
'created_at' => now(),
'updated_at' => now()
];
// Update stock langsung karena auto approve
$stock = Stock::firstOrCreate(
[
'product_id' => $productId,
'dealer_id' => $dealerId
],
['quantity' => 0]
);
// Update stock dengan physical stock
$stock->updateStock(
$physicalStock,
$opname,
"Stock adjustment from auto-approved opname #{$opname->id}"
);
}
// Validate we have at least one detail to insert
if (empty($details)) {
throw new \Exception('Tidak ada data stock fisik yang valid untuk diproses.');
}
// Bulk insert untuk performa lebih baik
OpnameDetail::insert($details);
// 10. Log aktivitas dengan detail produk yang diproses
$processedProducts = collect($details)->map(function($detail) {
return [
'product_id' => $detail['product_id'],
'system_stock' => $detail['system_stock'],
'physical_stock' => $detail['physical_stock'],
'difference' => $detail['difference']
];
});
Log::info('Opname created and auto-approved', [
'opname_id' => $opname->id,
'dealer_id' => $opname->dealer_id,
'user_id' => $userId,
'approver_id' => $userId,
'product_count' => count($details),
'processed_count' => $processedCount,
'form_type' => $isTransactionForm ? 'transaction' : 'regular',
'opname_date' => $opnameDate->format('Y-m-d'),
'processed_products' => $processedProducts->toArray()
]);
DB::commit();
if ($isTransactionForm) {
// Redirect back to transaction page with success message and tab indicator
return redirect()
->route('transaction')
->with('success', "Opname berhasil disimpan dan disetujui. {$processedCount} produk telah diproses.")
->with('active_tab', 'opname');
} else {
// Redirect to opname index for regular form
return redirect()
->route('opnames.index')
->with('success', "Opname berhasil disimpan dan disetujui. {$processedCount} produk telah diproses.");
}
} catch (\Illuminate\Validation\ValidationException $e) {
DB::rollBack();
Log::error('Validation error in OpnamesController@store', [
'errors' => $e->errors(),
'input' => $request->all()
]);
if ($isTransactionForm) {
return redirect()
->route('transaction')
->withErrors($e->validator)
->withInput()
->with('error', 'Terjadi kesalahan validasi. Periksa kembali data yang dimasukkan.')
->with('active_tab', 'opname');
} else {
return back()->withErrors($e->validator)->withInput();
}
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error in OpnamesController@store: ' . $e->getMessage());
Log::error($e->getTraceAsString());
Log::error('Request data:', $request->all());
$errorMessage = $e->getMessage();
if ($isTransactionForm) {
return redirect()
->route('transaction')
->with('error', $errorMessage)
->withInput()
->with('active_tab', 'opname');
} else {
return back()
->with('error', $errorMessage)
->withInput();
}
}
}
public function show(Request $request, $id)
{
try {
$opname = Opname::with('details.product', 'user')->findOrFail($id);
if ($request->ajax()) {
return DataTables::of($opname->details)
->addIndexColumn()
->addColumn('opname_date', function () use ($opname) {
return Carbon::parse($opname->opname_date)->format('d M Y');
})
->addColumn('user_name', function () use ($opname) {
return $opname->user ? $opname->user->name : '-';
})
->addColumn('product_name', function ($detail) {
return $detail->product->name ?? '-';
})
->addColumn('system_stock', function ($detail) {
return $detail->system_stock;
})
->addColumn('physical_stock', function ($detail) {
return $detail->physical_stock;
})
->addColumn('difference', function ($detail) {
return $detail->difference;
})
->make(true);
}
return view('warehouse_management.opnames.detail', compact('opname'));
} catch (\Exception $ex) {
Log::error($ex->getMessage());
abort(500, 'Something went wrong');
}
}
// Add new method to get stock data via AJAX
public function getStockData(Request $request)
{
try {
$dealerId = $request->dealer_id;
$productIds = $request->product_ids;
if (!$dealerId || !$productIds) {
return response()->json(['error' => 'Dealer ID dan Product IDs diperlukan'], 400);
}
$stocks = Stock::where('dealer_id', $dealerId)
->whereIn('product_id', $productIds)
->get()
->mapWithKeys(function ($stock) {
return [$stock->product_id => $stock->quantity];
});
return response()->json(['stocks' => $stocks]);
} catch (\Exception $e) {
Log::error('Error getting stock data: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data stok'], 500);
}
}
public function print($id)
{
try {
$opname = Opname::with(['details.product.category', 'user', 'dealer'])
->findOrFail($id);
return view('warehouse_management.opnames.print', compact('opname'));
} catch (\Exception $e) {
Log::error('Error printing opname: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print opname.');
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Menu;
use App\Models\ProductCategory;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Support\Facades\Gate;
class ProductCategoriesController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$menu = Menu::where('link','product_categories.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
if($request->ajax()){
$data = ProductCategory::query();
return DataTables::of($data)
->addIndexColumn()
->addColumn('parent', function ($row) {
return $row->parent ? $row->parent->name : '-';
})
->addColumn('action', function ($row) use ($menu) {
$btn = '';
if (Gate::allows('delete', $menu)) {
$btn .= '<button style="margin-right: 8px;" class="btn btn-danger btn-sm btn-destroy-product-category" data-action="' . route('product_categories.destroy', $row->id) . '" data-id="' . $row->id . '">Hapus</button>';
}
if (Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-edit-product-category" data-url="' . route('product_categories.edit', $row->id) . '" data-action="' . route('product_categories.update', $row->id) . '" data-id="' . $row->id . '">Edit</button>';
}
return $btn;
})
->rawColumns(['action'])
->make(true);
}
return view('warehouse_management.product_categories.index');
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => 'nullable|exists:product_categories,id',
]);
ProductCategory::create($validated);
return response()->json(['success' => true, 'message' => 'Kategori berhasil ditambahkan.']);
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$category = ProductCategory::findOrFail($id);
return response()->json($category);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$category = ProductCategory::findOrFail($id);
$category->update($validated);
return response()->json(['success' => true, 'message' => 'Kategori berhasil diperbarui.']);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
ProductCategory::findOrFail($id)->delete();
return response()->json(['success' => true, 'message' => 'Kategorii berhasil dihapus.']);
}
public function product_category_parents(Request $request)
{
$parents = ProductCategory::whereNull('parent_id')->get(['id', 'name']);
return response()->json($parents);
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Dealer;
use App\Models\Menu;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Exports\ProductStockDealers;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Facades\Excel;
class ProductsController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$menu = Menu::where('link','products.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
if($request->ajax()){
Log::info('Products DataTables request received');
Log::info('Request parameters:', $request->all());
try {
// Check if products exist
$productCount = Product::count();
Log::info('Total products in database: ' . $productCount);
$data = Product::with(['category', 'stocks'])
->select(['id', 'code', 'name', 'product_category_id', 'unit', 'active']);
Log::info('Query built, executing DataTables...');
return DataTables::of($data)
->addIndexColumn()
->addColumn('code', function ($row) {
return $row->code;
})
->addColumn('name', function ($row) {
return $row->name;
})
->addColumn('category_name', function ($row) {
return $row->category ? $row->category->name : '-';
})
->addColumn('unit', function ($row) {
return $row->unit ?? '-';
})
->addColumn('total_stock', function ($row){
try {
$totalStock = $row->stocks()->sum('quantity');
return number_format($totalStock, 2);
} catch (\Exception $e) {
Log::error('Error calculating total stock for product ' . $row->id . ': ' . $e->getMessage());
return '0.00';
}
})
->addColumn('action', function ($row) use ($menu) {
$btn = '<div class="d-flex">';
if (Gate::allows('update', $menu)) {
$btn .= '<a href="' . route('products.edit', $row->id) . '" class="btn btn-warning btn-sm" style="margin-right: 8px;">Edit</a>';
$btn .= '<button class="btn btn-sm btn-toggle-active '
. ($row->active ? 'btn-danger' : 'btn-success') . '"
data-url="' . route('products.toggleActive', $row->id) . '" data-active="'.$row->active.'" style="margin-right: 8px;">'
. ($row->active ? 'Nonaktifkan' : 'Aktifkan') . '</button>';
}
$btn .= '<button class="btn btn-sm btn-secondary btn-product-stock-dealers"
data-id="'.$row->id.'"
data-url="'.route('products.dealers_stock').'"
data-name="'.$row->name.'">Dealer</button>';
$btn .= '</div>';
return $btn;
})
->filterColumn('category_name', function($query, $keyword) {
$query->whereHas('category', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->orderColumn('code', function ($query, $order) {
$query->orderBy('products.code', $order);
})
->orderColumn('name', function ($query, $order) {
$query->orderBy('products.name', $order);
})
->orderColumn('category_name', function ($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM product_categories WHERE product_categories.id = products.product_category_id)'),
$order
);
})
->orderColumn('unit', function ($query, $order) {
$query->orderBy('products.unit', $order);
})
->rawColumns(['action'])
->make(true);
} catch (\Exception $e) {
Log::error('Products DataTables error: ' . $e->getMessage());
Log::error('Stack trace: ' . $e->getTraceAsString());
return response()->json(['error' => 'Failed to load data'], 500);
}
}
return view('warehouse_management.products.index');
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$categories = ProductCategory::with('children')->whereNull('parent_id')->get();
$dealers = Dealer::all();
return view('warehouse_management.products.create', compact('categories', 'dealers'));
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
try{
$request->validate([
'code' => [
'required',
'string',
Rule::unique('products')->whereNull('deleted_at'),
],
'name' => 'required|string',
'description' => 'nullable|string',
'unit' => 'nullable|string',
'active' => 'required|boolean',
'product_category_id' => 'required|exists:product_categories,id'
]);
// Create product
$product = Product::create([
'code' => $request->code,
'name' => $request->name,
'unit' => $request->unit,
'active' => $request->active,
'description' => $request->description,
'product_category_id' => $request->product_category_id,
]);
return redirect()->route('products.index')->with('success', 'Produk berhasil ditambahkan.');
}catch(\Exception $ex){
Log::error($ex->getMessage());
throw $ex;
}
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$product = Product::findOrFail($id);
return view('warehouse_management.products.edit', [
'product' => $product->load('dealers'),
'dealers' => Dealer::all(),
'categories' => ProductCategory::with('children')->get(),
]);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Product $product)
{
try{
$request->validate([
'code' => [
'required',
'string',
Rule::unique('products')->ignore($product->id)->whereNull('deleted_at'),
],
'name' => 'required|string',
'description' => 'nullable|string',
'unit' => 'nullable|string',
'active' => 'required|boolean',
'product_category_id' => 'required|exists:product_categories,id'
]);
$product->update($request->only(['code', 'name', 'description', 'unit','active', 'product_category_id']));
return redirect()->route('products.index')->with('success', 'Produk berhasil diperbarui.');
}catch(\Exception $ex){
Log::error($ex->getMessage());
}
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->route('products.index')->with('success', 'Produk berhasil dihapus.');
}
public function toggleActive(Request $request, Product $product)
{
$product->active = !$product->active;
$product->save();
return response()->json([
'success' => true,
'active' => $product->active,
'message' => 'Status produk berhasil diperbarui.'
]);
}
public function all_products(){
try{
$products = Product::where('active', true)->select('id','name')->get();
return response()->json($products);
}catch(\Exception $ex){
Log::error($ex->getMessage());
}
}
public function dealers_stock(Request $request){
$productId = $request->get('product_id');
$product = Product::with(['stocks.dealer'])->findOrFail($productId);
$data = $product->stocks->map(function ($stock) {
return [
'dealer_name' => $stock->dealer->name ?? '-',
'quantity' => $stock->quantity
];
});
return DataTables::of($data)->make(true);
}
public function exportDealersStock()
{
try {
$fileName = 'stok_produk_dealers_' . date('Y-m-d_H-i-s') . '.xlsx';
return Excel::download(new ProductStockDealers(), $fileName);
} catch (\Exception $e) {
Log::error('Export dealers stock error: ' . $e->getMessage());
return back()->with('error', 'Gagal mengexport data. Silakan coba lagi.');
}
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\StockLog;
use App\Models\Menu;
use App\Models\Dealer;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
use Illuminate\Support\Facades\Gate;
class StockAuditController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link', 'stock-audit.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
$products = Product::all();
if ($request->ajax()) {
Log::info('Stock audit ajax request received', [
'filters' => $request->only(['dealer', 'product', 'change_type', 'date']),
'user_id' => auth()->id(),
'user_dealer_id' => auth()->user()->dealer_id
]);
$data = StockLog::query()
->with([
'stock.product',
'stock.dealer',
'user.role',
'source'
])
->leftJoin('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
->leftJoin('products', 'stocks.product_id', '=', 'products.id')
->leftJoin('dealers', 'stocks.dealer_id', '=', 'dealers.id')
->leftJoin('users', 'stock_logs.user_id', '=', 'users.id')
->select('stock_logs.*');
// Apply filters from request
if ($request->filled('dealer')) {
$data->where('dealers.name', 'like', '%' . $request->dealer . '%');
}
if ($request->filled('product')) {
$data->where('products.name', 'like', '%' . $request->product . '%');
}
if ($request->filled('change_type')) {
$data->where('stock_logs.change_type', $request->change_type);
}
if ($request->filled('date')) {
$data->whereDate('stock_logs.created_at', $request->date);
}
return DataTables::of($data)
->addIndexColumn()
->addColumn('product_name', function($row) {
return $row->stock->product->name ?? '-';
})
->addColumn('dealer_name', function($row) {
return $row->stock->dealer->name ?? '-';
})
->addColumn('change_type', function($row) {
$changeType = $row->change_type;
$class = match($changeType->value) {
'increase' => 'text-success',
'decrease' => 'text-danger',
'adjustment' => 'text-warning',
'no_change' => 'text-muted',
default => 'text-dark'
};
return "<span class=\"font-weight-bold {$class}\">{$changeType->label()}</span>";
})
->addColumn('quantity_change', function($row) {
$change = $row->quantity_change;
if ($change > 0) {
return "<span class=\"text-success\">+{$change}</span>";
} elseif ($change < 0) {
return "<span class=\"text-danger\">{$change}</span>";
} else {
return "<span class=\"text-muted\">0</span>";
}
})
->addColumn('stock_before_after', function($row) {
return "{$row->previous_quantity}{$row->new_quantity}";
})
->addColumn('source_info', function($row) {
if ($row->source_type === 'App\\Models\\Mutation') {
$mutationNumber = $row->source ? $row->source->mutation_number : '-';
return "Mutasi: {$mutationNumber}";
} elseif ($row->source_type === 'App\\Models\\Opname') {
$opname_id = $row->source ? $row->source->id : '-';
return "Opname: #{$opname_id}";
} elseif ($row->source_type === 'App\\Models\\Transaction')
{
$transaction_id = $row->source ? $row->source->id : '-';
return "Transaksi: #{$transaction_id}";
}else {
return $row->source_type ?? '-';
}
})
->addColumn('user_name', function($row) {
return $row->user->name ?? '-';
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) {
$buttons = '<button type="button" class="btn btn-info btn-sm" onclick="showAuditDetail('.$row->id.')">
Detail
</button>';
return $buttons;
})
// Filtering
->filterColumn('product_name', function($query, $keyword) {
$query->where('products.name', 'like', "%{$keyword}%");
})
->filterColumn('dealer_name', function($query, $keyword) {
$query->where('dealers.name', 'like', "%{$keyword}%");
})
->filterColumn('change_type', function($query, $keyword) {
$query->where('stock_logs.change_type', 'like', "%{$keyword}%");
})
->filterColumn('source_info', function($query, $keyword) {
$query->where(function($q) use ($keyword) {
$q->where('stock_logs.source_type', 'like', "%{$keyword}%")
->orWhere('stock_logs.description', 'like', "%{$keyword}%");
});
})
->filterColumn('user_name', function($query, $keyword) {
$query->where('users.name', 'like', "%{$keyword}%");
})
->filterColumn('created_at', function($query, $keyword) {
$query->whereDate('stock_logs.created_at', 'like', "%{$keyword}%");
})
// Order column mapping
->orderColumn('product_name', function($query, $order) {
return $query->orderBy('products.name', $order);
})
->orderColumn('dealer_name', function($query, $order) {
return $query->orderBy('dealers.name', $order);
})
->orderColumn('user_name', function($query, $order) {
return $query->orderBy('users.name', $order);
})
->orderColumn('created_at', function($query, $order) {
return $query->orderBy('stock_logs.created_at', $order);
})
->orderColumn('quantity_change', function($query, $order) {
return $query->orderBy('stock_logs.quantity_change', $order);
})
->orderColumn('stock_before_after', function($query, $order) {
return $query->orderBy('stock_logs.previous_quantity', $order);
})
->orderColumn('change_type', function($query, $order) {
return $query->orderBy('stock_logs.change_type', $order);
})
->orderColumn('source_info', function($query, $order) {
return $query->orderBy('stock_logs.source_type', $order);
})
->rawColumns(['change_type', 'quantity_change', 'action'])
->make(true);
}
return view('warehouse_management.stock_audit.index', compact('menu', 'dealers', 'products'));
}
public function getDetail(StockLog $stockLog)
{
try {
$stockLog->load([
'stock.product',
'stock.dealer',
'user.role',
'source'
]);
// Format data untuk response
$stockLog->created_at_formatted = $stockLog->created_at->format('d M Y, H:i');
$stockLog->change_type_label = $stockLog->change_type->label();
// Detail source berdasarkan tipe
$sourceDetail = null;
if ($stockLog->source) {
if ($stockLog->source_type === 'App\\Models\\Mutation') {
$mutation = $stockLog->source;
$mutation->load(['fromDealer', 'toDealer', 'requestedBy', 'approvedBy']);
// Format approved_at date if exists
if ($mutation->approved_at) {
$mutation->approved_at_formatted = $mutation->approved_at->format('d M Y, H:i');
}
$sourceDetail = [
'type' => 'mutation',
'data' => $mutation
];
} elseif ($stockLog->source_type === 'App\\Models\\StockOpname') {
$opname = $stockLog->source;
$opname->load(['dealer', 'user']);
$sourceDetail = [
'type' => 'opname',
'data' => $opname
];
}
}
return response()->json([
'success' => true,
'data' => $stockLog,
'source_detail' => $sourceDetail
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal memuat detail audit: ' . $e->getMessage()
], 500);
}
}
}

22
app/Http/Controllers/WorkController.php Normal file → Executable file
View File

@@ -26,16 +26,28 @@ class WorkController extends Controller
$data = DB::table('works as w')->leftJoin('categories as c', 'c.id', '=', 'w.category_id')->select('w.shortname as shortname', 'w.id as work_id', 'w.name as name', 'w.desc as desc', 'c.name as category_name', 'w.category_id as category_id');
return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) {
$btn = '';
$btn = '<div class="d-flex flex-row gap-1">';
if(Auth::user()->can('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('work.destroy', $row->work_id) .'" id="destroyWork'. $row->work_id .'" onclick="destroyWork('. $row->work_id .')"> Hapus </button>';
// Products Management Button
if(Gate::allows('view', $menu)) {
$btn .= '<a href="'. route('work.products.index', ['work' => $row->work_id]) .'" class="btn btn-info btn-sm" title="Kelola Produk">
Produk
</a>';
}
if(Auth::user()->can('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editWork'. $row->work_id .'" data-url="'. route('work.edit', $row->work_id) .'" data-action="'. route('work.update', $row->work_id) .'" onclick="editWork('. $row->work_id .')"> Edit </button>';
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm" id="editWork'. $row->work_id .'" data-url="'. route('work.edit', $row->work_id) .'" data-action="'. route('work.update', $row->work_id) .'" onclick="editWork('. $row->work_id .')">
Edit
</button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm" data-action="'. route('work.destroy', $row->work_id) .'" id="destroyWork'. $row->work_id .'" onclick="destroyWork('. $row->work_id .')">
Hapus
</button>';
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers;
use App\Models\Work;
use App\Models\Product;
use App\Models\WorkProduct;
use App\Models\Menu;
use App\Services\StockService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
class WorkProductController extends Controller
{
protected $stockService;
public function __construct(StockService $stockService)
{
$this->stockService = $stockService;
}
/**
* Display work products for a specific work
*/
public function index(Request $request, $workId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$work = Work::with('category')->findOrFail($workId);
if ($request->ajax()) {
Log::info('Work products index AJAX request for work ID: ' . $workId);
$workProducts = WorkProduct::with(['product', 'product.category'])
->where('work_id', $workId)
->get();
Log::info('Found ' . $workProducts->count() . ' work products');
return DataTables::of($workProducts)
->addIndexColumn()
->addColumn('product_name', function($row) {
return $row->product->name;
})
->addColumn('product_code', function($row) {
return $row->product->code;
})
->addColumn('product_category', function($row) {
return $row->product->category ? $row->product->category->name : '-';
})
->addColumn('unit', function($row) {
return $row->product->unit;
})
->addColumn('quantity_required', function($row) {
return number_format($row->quantity_required, 2);
})
->addColumn('action', function($row) use ($menu) {
$btn = '<div class="d-flex flex-row gap-1">';
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-edit-work-product" data-id="'.$row->id.'">
Edit
</button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-delete-work-product" data-id="'.$row->id.'">
Hapus
</button>';
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])
->make(true);
}
$products = Product::where('active', true)->with('category')->get();
return view('back.master.work-products', compact('work', 'products'));
}
/**
* Store work product relationship
*/
public function store(Request $request)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('create', $menu), 403, 'Unauthorized User');
$request->validate([
'work_id' => 'required|exists:works,id',
'product_id' => 'required|exists:products,id',
'quantity_required' => 'required|numeric|min:0.01',
'notes' => 'nullable|string'
]);
// Check if combination already exists
$exists = WorkProduct::where('work_id', $request->work_id)
->where('product_id', $request->product_id)
->exists();
if ($exists) {
return response()->json([
'status' => 422,
'message' => 'Produk sudah ditambahkan ke pekerjaan ini'
], 422);
}
WorkProduct::create($request->all());
return response()->json([
'status' => 200,
'message' => 'Produk berhasil ditambahkan ke pekerjaan'
]);
}
/**
* Show work product for editing
*/
public function show($workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
try {
$workProduct = WorkProduct::with(['work', 'product', 'product.category'])
->where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
return response()->json([
'status' => 200,
'data' => $workProduct
]);
} catch (\Exception $e) {
Log::error('Error fetching work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Update work product relationship
*/
public function update(Request $request, $workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('update', $menu), 403, 'Unauthorized User');
$request->validate([
'quantity_required' => 'required|numeric|min:0.01',
'notes' => 'nullable|string'
]);
try {
$workProduct = WorkProduct::where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
$workProduct->update($request->only(['quantity_required', 'notes']));
return response()->json([
'status' => 200,
'message' => 'Data produk pekerjaan berhasil diupdate'
]);
} catch (\Exception $e) {
Log::error('Error updating work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Remove work product relationship
*/
public function destroy($workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('delete', $menu), 403, 'Unauthorized User');
try {
$workProduct = WorkProduct::where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
$workProduct->delete();
return response()->json([
'status' => 200,
'message' => 'Produk berhasil dihapus dari pekerjaan'
]);
} catch (\Exception $e) {
Log::error('Error deleting work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Get stock prediction for work
*/
public function stockPrediction(Request $request, $workId)
{
$quantity = $request->get('quantity', 1);
$prediction = $this->stockService->getStockUsagePrediction($workId, $quantity);
return response()->json([
'status' => 200,
'data' => $prediction
]);
}
/**
* Check stock availability for work at specific dealer
*/
public function checkStock(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
'quantity' => 'required|integer|min:1'
]);
$availability = $this->stockService->checkStockAvailability(
$request->work_id,
$request->dealer_id,
$request->quantity
);
return response()->json([
'status' => 200,
'data' => $availability
]);
}
}

0
app/Http/Kernel.php Normal file → Executable file
View File

0
app/Http/Middleware/Authenticate.php Normal file → Executable file
View File

0
app/Http/Middleware/EncryptCookies.php Normal file → Executable file
View File

View File

0
app/Http/Middleware/RedirectIfAuthenticated.php Normal file → Executable file
View File

0
app/Http/Middleware/TrimStrings.php Normal file → Executable file
View File

0
app/Http/Middleware/TrustHosts.php Normal file → Executable file
View File

0
app/Http/Middleware/TrustProxies.php Normal file → Executable file
View File

0
app/Http/Middleware/VerifyCsrfToken.php Normal file → Executable file
View File

0
app/Http/Middleware/adminRole.php Normal file → Executable file
View File

0
app/Http/Middleware/mechanicRole.php Normal file → Executable file
View File

0
app/Models/Category.php Normal file → Executable file
View File

26
app/Models/Dealer.php Normal file → Executable file
View File

@@ -22,4 +22,30 @@ class Dealer extends Model
{
return $this->hasMany(Transaction::class, 'dealer_id', 'id');
}
public function opnames(){
return $this->hasMany(Opname::class);
}
public function outgoingMutations()
{
return $this->hasMany(Mutation::class, 'from_dealer_id');
}
public function incomingMutations()
{
return $this->hasMany(Mutation::class, 'to_dealer_id');
}
public function stocks()
{
return $this->hasMany(Stock::class);
}
public function products()
{
return $this->belongsToMany(Product::class, 'stocks', 'dealer_id', 'product_id')
->withPivot('quantity')
->withTimestamps();
}
}

0
app/Models/Menu.php Normal file → Executable file
View File

286
app/Models/Mutation.php Executable file
View File

@@ -0,0 +1,286 @@
<?php
namespace App\Models;
use App\Enums\MutationStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Mutation extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'mutation_number',
'from_dealer_id',
'to_dealer_id',
'status',
'requested_by',
'approved_by',
'approved_at',
'approval_notes',
'received_by',
'received_at',
'reception_notes',
'shipping_notes',
'rejection_reason',
'rejected_by',
'rejected_at',
'cancelled_by',
'cancelled_at',
'cancellation_reason'
];
protected $casts = [
'status' => MutationStatus::class,
'approved_at' => 'datetime',
'received_at' => 'datetime',
'rejected_at' => 'datetime',
'cancelled_at' => 'datetime'
];
protected static function booted()
{
static::creating(function ($mutation) {
if (empty($mutation->mutation_number)) {
$mutation->mutation_number = $mutation->generateMutationNumber();
}
});
}
public function fromDealer()
{
return $this->belongsTo(Dealer::class, 'from_dealer_id');
}
public function toDealer()
{
return $this->belongsTo(Dealer::class, 'to_dealer_id');
}
public function requestedBy()
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approvedBy()
{
return $this->belongsTo(User::class, 'approved_by');
}
public function receivedBy()
{
return $this->belongsTo(User::class, 'received_by');
}
public function rejectedBy()
{
return $this->belongsTo(User::class, 'rejected_by');
}
public function cancelledBy()
{
return $this->belongsTo(User::class, 'cancelled_by');
}
public function mutationDetails()
{
return $this->hasMany(MutationDetail::class);
}
public function stockLogs()
{
return $this->morphMany(StockLog::class, 'source');
}
// Helper methods
public function getStatusLabelAttribute()
{
return $this->status->label();
}
public function getStatusColorAttribute()
{
return $this->status->color();
}
public function getTotalItemsAttribute()
{
return $this->mutationDetails()->sum('quantity_requested');
}
public function getTotalApprovedItemsAttribute()
{
return $this->mutationDetails()->sum('quantity_approved');
}
public function canBeReceived()
{
return $this->status === MutationStatus::SENT;
}
public function canBeApproved()
{
return $this->status === MutationStatus::RECEIVED;
}
public function canBeCancelled()
{
return $this->status === MutationStatus::SENT;
}
// Receive mutation by destination dealer
public function receive($userId, $receptionNotes = null)
{
if (!$this->canBeReceived()) {
throw new \Exception('Mutasi tidak dapat diterima dalam status saat ini');
}
$this->update([
'status' => MutationStatus::RECEIVED,
'received_by' => $userId,
'received_at' => now(),
'reception_notes' => $receptionNotes
]);
return $this;
}
// Approve mutation and move stock immediately
public function approve($userId, $approvalNotes = null)
{
if (!$this->canBeApproved()) {
throw new \Exception('Mutasi tidak dapat disetujui dalam status saat ini');
}
DB::beginTransaction();
try {
// Update status to approved first
$this->update([
'status' => MutationStatus::APPROVED,
'approved_by' => $userId,
'approved_at' => now(),
'approval_notes' => $approvalNotes
]);
// Immediately move stock after approval
foreach ($this->mutationDetails as $detail) {
// Process all details that have quantity_requested > 0
// because goods have been sent from source dealer
if ($detail->quantity_requested > 0) {
$this->processStockMovement($detail);
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
return $this;
}
// Reject mutation
public function reject($userId, $rejectionReason)
{
if (!$this->canBeApproved()) {
throw new \Exception('Mutasi tidak dapat ditolak dalam status saat ini');
}
$this->update([
'status' => MutationStatus::REJECTED,
'rejected_by' => $userId,
'rejected_at' => now(),
'rejection_reason' => $rejectionReason
]);
return $this;
}
// Cancel mutation
public function cancel($userId, $cancellationReason = null)
{
if (!$this->canBeCancelled()) {
throw new \Exception('Mutasi tidak dapat dibatalkan dalam status saat ini');
}
$this->update([
'status' => MutationStatus::CANCELLED,
'cancelled_by' => $userId,
'cancelled_at' => now(),
'cancellation_reason' => $cancellationReason
]);
return $this;
}
// Complete method removed - Stock moves automatically after approval
private function processStockMovement(MutationDetail $detail)
{
// Kurangi stock dari dealer asal berdasarkan quantity_requested (barang yang dikirim)
$fromStock = Stock::firstOrCreate([
'product_id' => $detail->product_id,
'dealer_id' => $this->from_dealer_id
], ['quantity' => 0]);
if ($fromStock->quantity < $detail->quantity_requested) {
throw new \Exception("Stock tidak mencukupi untuk produk {$detail->product->name} di {$this->fromDealer->name}");
}
$fromStock->updateStock(
$fromStock->quantity - $detail->quantity_requested,
$this,
"Mutasi keluar ke {$this->toDealer->name} - {$this->mutation_number} (Dikirim: {$detail->quantity_requested}, Diterima: {$detail->quantity_approved})"
);
// Tambah stock ke dealer tujuan berdasarkan quantity_approved (barang yang diterima)
$toStock = Stock::firstOrCreate([
'product_id' => $detail->product_id,
'dealer_id' => $this->to_dealer_id
], ['quantity' => 0]);
$toStock->updateStock(
$toStock->quantity + $detail->quantity_approved,
$this,
"Mutasi masuk dari {$this->fromDealer->name} - {$this->mutation_number} (Dikirim: {$detail->quantity_requested}, Diterima: {$detail->quantity_approved})"
);
// Jika ada selisih (kehilangan), catat sebagai stock log terpisah untuk audit
$lostQuantity = $detail->quantity_requested - $detail->quantity_approved;
if ($lostQuantity > 0) {
// Buat stock log untuk barang yang hilang/rusak
StockLog::create([
'stock_id' => $fromStock->id,
'previous_quantity' => $fromStock->quantity + $detail->quantity_requested, // Stock sebelum pengurangan
'new_quantity' => $fromStock->quantity, // Stock setelah pengurangan
'source_type' => get_class($this),
'source_id' => $this->id,
'description' => "Kehilangan/kerusakan saat mutasi ke {$this->toDealer->name} - {$this->mutation_number} (Hilang: {$lostQuantity})",
'user_id' => auth()->id()
]);
}
}
private function generateMutationNumber()
{
$prefix = 'MUT';
$date = now()->format('Ymd');
$lastNumber = static::whereDate('created_at', today())
->whereNotNull('mutation_number')
->latest('id')
->first();
if ($lastNumber) {
$lastSequence = (int) substr($lastNumber->mutation_number, -4);
$sequence = str_pad($lastSequence + 1, 4, '0', STR_PAD_LEFT);
} else {
$sequence = '0001';
}
return "{$prefix}{$date}{$sequence}";
}
}

116
app/Models/MutationDetail.php Executable file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MutationDetail extends Model
{
use HasFactory;
protected $fillable = [
'mutation_id',
'product_id',
'quantity_requested',
'quantity_approved',
'notes'
];
protected $casts = [
'quantity_requested' => 'decimal:2',
'quantity_approved' => 'decimal:2'
];
public function mutation()
{
return $this->belongsTo(Mutation::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
// Helper methods
public function getQuantityDifferenceAttribute()
{
return $this->quantity_approved - $this->quantity_requested;
}
public function isFullyApproved()
{
return $this->quantity_approved !== null && $this->quantity_approved == $this->quantity_requested;
}
public function isPartiallyApproved()
{
return $this->quantity_approved !== null && $this->quantity_approved > 0 && $this->quantity_approved < $this->quantity_requested;
}
public function isRejected()
{
// Hanya dianggap ditolak jika mutasi sudah di-approve/reject dan quantity_approved = 0
$mutationStatus = $this->mutation->status->value ?? null;
return in_array($mutationStatus, ['approved', 'rejected']) && $this->quantity_approved == 0;
}
public function getApprovalStatusAttribute()
{
$mutationStatus = $this->mutation->status->value ?? null;
// Jika mutasi belum di-approve, semua detail statusnya "Menunggu"
if (!in_array($mutationStatus, ['approved', 'rejected'])) {
return 'Menunggu';
}
// Jika mutasi sudah di-approve, baru cek quantity_approved
if ($this->isFullyApproved()) {
return 'Disetujui Penuh';
} elseif ($this->isPartiallyApproved()) {
return 'Disetujui Sebagian';
} elseif ($this->isRejected()) {
return 'Ditolak';
} else {
return 'Menunggu';
}
}
public function getApprovalStatusColorAttribute()
{
$mutationStatus = $this->mutation->status->value ?? null;
// Jika mutasi belum di-approve, semua detail statusnya "info" (menunggu)
if (!in_array($mutationStatus, ['approved', 'rejected'])) {
return 'info';
}
// Jika mutasi sudah di-approve, baru cek quantity_approved
if ($this->isFullyApproved()) {
return 'success';
} elseif ($this->isPartiallyApproved()) {
return 'warning';
} elseif ($this->isRejected()) {
return 'danger';
} else {
return 'info';
}
}
// Scope untuk filter berdasarkan status approval
public function scopeFullyApproved($query)
{
return $query->whereColumn('quantity_approved', '=', 'quantity_requested');
}
public function scopePartiallyApproved($query)
{
return $query->where('quantity_approved', '>', 0)
->whereColumn('quantity_approved', '<', 'quantity_requested');
}
public function scopeRejected($query)
{
return $query->where('quantity_approved', '=', 0);
}
}

119
app/Models/Opname.php Executable file
View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models;
use App\Enums\OpnameStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Opname extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'dealer_id',
'opname_date',
'user_id',
'note',
'status',
'approved_by',
'approved_at',
'rejection_note'
];
protected $casts = [
'approved_at' => 'datetime',
'status' => OpnameStatus::class
];
protected static function booted()
{
static::updated(function ($opname) {
// Jika status berubah menjadi approved
if ($opname->isDirty('status') && $opname->status === OpnameStatus::APPROVED) {
// Update stock untuk setiap detail opname
foreach ($opname->details as $detail) {
$stock = Stock::firstOrCreate(
[
'product_id' => $detail->product_id,
'dealer_id' => $opname->dealer_id
],
['quantity' => 0]
);
// Update stock dengan physical_stock dari opname
$stock->updateStock(
$detail->physical_stock,
$opname,
"Stock adjustment from approved opname #{$opname->id}"
);
}
}
});
}
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
public function details()
{
return $this->hasMany(OpnameDetail::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function approver()
{
return $this->belongsTo(User::class, 'approved_by');
}
// Method untuk approve opname
public function approve(User $approver)
{
if ($this->status !== OpnameStatus::PENDING) {
throw new \Exception('Only pending opnames can be approved');
}
$this->status = OpnameStatus::APPROVED;
$this->approved_by = $approver->id;
$this->approved_at = now();
$this->save();
return $this;
}
// Method untuk reject opname
public function reject(User $rejector, string $note)
{
if ($this->status !== OpnameStatus::PENDING) {
throw new \Exception('Only pending opnames can be rejected');
}
$this->status = OpnameStatus::REJECTED;
$this->approved_by = $rejector->id;
$this->approved_at = now();
$this->rejection_note = $note;
$this->save();
return $this;
}
// Method untuk submit opname untuk approval
public function submit()
{
if ($this->status !== OpnameStatus::DRAFT) {
throw new \Exception('Only draft opnames can be submitted');
}
$this->status = OpnameStatus::PENDING;
$this->save();
return $this;
}
}

30
app/Models/OpnameDetail.php Executable file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class OpnameDetail extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'opname_id',
'product_id',
'physical_stock',
'system_stock',
'difference',
'note',
];
public function opname()
{
return $this->belongsTo(Opname::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

0
app/Models/Privilege.php Normal file → Executable file
View File

72
app/Models/Product.php Executable file
View File

@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['code','name','description','unit','active','product_category_id'];
public function category(){
return $this->belongsTo(ProductCategory::class, 'product_category_id');
}
public function opnameDetails(){
return $this->hasMany(OpnameDetail::class);
}
public function stocks(){
return $this->hasMany(Stock::class);
}
public function mutationDetails()
{
return $this->hasMany(MutationDetail::class);
}
public function dealers()
{
return $this->belongsToMany(Dealer::class, 'stocks', 'product_id', 'dealer_id')
->withPivot('quantity')
->withTimestamps();
}
// Helper method untuk mendapatkan total stock saat ini
public function getCurrentTotalStockAttribute()
{
return $this->stocks()->sum('quantity');
}
// Helper method untuk mendapatkan stock di dealer tertentu
public function getStockByDealer($dealerId)
{
return $this->stocks()->where('dealer_id', $dealerId)->first()?->quantity ?? 0;
}
/**
* Get all works that require this product
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function works()
{
return $this->belongsToMany(Work::class, 'work_products')
->withPivot('quantity_required', 'notes')
->withTimestamps();
}
/**
* Get work products pivot records
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workProducts()
{
return $this->hasMany(WorkProduct::class);
}
}

26
app/Models/ProductCategory.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ProductCategory extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name','parent_id'];
public function products(){
return $this->hasMany(Product::class, 'product_category_id');
}
public function parent(){
return $this->belongsTo(ProductCategory::class, 'parent_id');
}
public function children(){
return $this->hasMany(ProductCategory::class,'parent_id');
}
}

0
app/Models/Role.php Normal file → Executable file
View File

56
app/Models/Stock.php Executable file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Stock extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'dealer_id',
'quantity'
];
public function product()
{
return $this->belongsTo(Product::class);
}
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
public function stockLogs()
{
return $this->hasMany(StockLog::class);
}
// Method untuk mengupdate stock
public function updateStock($newQuantity, $source, $description = null)
{
$previousQuantity = $this->quantity;
$quantityChange = $newQuantity - $previousQuantity;
$this->quantity = $newQuantity;
$this->save();
// Buat log perubahan
StockLog::create([
'stock_id' => $this->id,
'source_type' => get_class($source),
'source_id' => $source->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity,
'quantity_change' => $quantityChange,
'description' => $description,
'user_id' => auth()->id()
]);
return $this;
}
}

70
app/Models/StockLog.php Executable file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use App\Enums\StockChangeType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StockLog extends Model
{
use HasFactory;
protected $fillable = [
'stock_id',
'source_type',
'source_id',
'previous_quantity',
'new_quantity',
'quantity_change',
'change_type',
'description',
'user_id'
];
protected $casts = [
'change_type' => StockChangeType::class,
'previous_quantity' => 'decimal:2',
'new_quantity' => 'decimal:2',
'quantity_change' => 'decimal:2'
];
protected static function booted()
{
static::creating(function ($stockLog) {
// Hitung quantity_change
$stockLog->quantity_change = $stockLog->new_quantity - $stockLog->previous_quantity;
// Tentukan change_type berdasarkan quantity_change
if ($stockLog->quantity_change == 0) {
// Jika quantity sama persis (tanpa toleransi)
$stockLog->change_type = StockChangeType::NO_CHANGE;
} else if ($stockLog->quantity_change > 0) {
$stockLog->change_type = StockChangeType::INCREASE;
} else {
$stockLog->change_type = StockChangeType::DECREASE;
}
});
}
public function stock()
{
return $this->belongsTo(Stock::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function source()
{
return $this->morphTo();
}
// Helper method untuk mendapatkan label change_type
public function getChangeTypeLabelAttribute()
{
return $this->change_type->label();
}
}

34
app/Models/Transaction.php Normal file → Executable file
View File

@@ -16,10 +16,40 @@ class Transaction extends Model
/**
* Get the work associated with the Transaction
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function work()
{
return $this->hasOne(Work::class, 'id', 'work_id');
return $this->belongsTo(Work::class, 'work_id', 'id');
}
/**
* Get the dealer associated with the Transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
/**
* Get the user who created the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get the SA user associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function userSa()
{
return $this->belongsTo(User::class, 'user_sa_id');
}
}

57
app/Models/User.php Normal file → Executable file
View File

@@ -75,4 +75,61 @@ class User extends Authenticatable
{
return $this->hasOne(Dealer::class, 'id', 'dealer_id');
}
/**
* Get the role associated with the User
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role()
{
return $this->belongsTo(Role::class, 'role_id');
}
/**
* Check if user has a specific role
*
* @param string $roleName
* @return bool
*/
public function hasRole($roleName)
{
// If role_id is 0 or null, user has no role
if (!$this->role_id) {
return false;
}
// For admin role, we can check if user has admin privileges
if (strtolower($roleName) === 'admin') {
return $this->isAdmin();
}
// Load role if not already loaded
if (!$this->relationLoaded('role')) {
$this->load('role');
}
return $this->role && strtolower($this->role->name) === strtolower($roleName);
}
/**
* Check if user is admin by checking admin privileges
*
* @return bool
*/
public function isAdmin()
{
// Check if user has admin privileges by checking if they can access admin area
try {
$adminPrivilege = \App\Models\Privilege::join('menus', 'menus.id', '=', 'privileges.menu_id')
->where('menus.link', 'adminarea')
->where('privileges.role_id', $this->role_id)
->where('privileges.view', 1)
->first();
return $adminPrivilege !== null;
} catch (\Exception $e) {
return false;
}
}
}

32
app/Models/Work.php Normal file → Executable file
View File

@@ -22,4 +22,36 @@ class Work extends Model
{
return $this->hasMany(Transaction::class, 'work_id', 'id');
}
/**
* Get all products required for this work
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function products()
{
return $this->belongsToMany(Product::class, 'work_products')
->withPivot('quantity_required', 'notes')
->withTimestamps();
}
/**
* Get work products pivot records
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workProducts()
{
return $this->hasMany(WorkProduct::class);
}
/**
* Get the category associated with the Work
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function category()
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WorkProduct extends Model
{
use HasFactory;
protected $fillable = [
'work_id',
'product_id',
'quantity_required',
'notes'
];
protected $casts = [
'quantity_required' => 'decimal:2'
];
public function work()
{
return $this->belongsTo(Work::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

24
app/Providers/AppServiceProvider.php Normal file → Executable file
View File

@@ -3,8 +3,10 @@
namespace App\Providers;
use App\Models\Menu;
use Carbon\Carbon;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\URL;
class AppServiceProvider extends ServiceProvider
{
@@ -15,7 +17,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register()
{
//
$this->app->singleton(\App\Services\StockService::class, function ($app) {
return new \App\Services\StockService();
});
}
/**
@@ -25,7 +29,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*'], function ($view) {
Carbon::setLocale('id');
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*'], function ($view) {
$menuQuery = Menu::all();
$menus = [];
foreach($menuQuery as $menu) {
@@ -34,5 +39,20 @@ class AppServiceProvider extends ServiceProvider
$view->with('menus', $menus);
});
// Force HTTPS in production if needed
if (config('app.env') === 'production') {
// Force the application URL to include port if specified
$appUrl = config('app.url');
if ($appUrl) {
URL::forceRootUrl($appUrl);
// Parse URL to check if it's HTTPS
$parsedUrl = parse_url($appUrl);
if (isset($parsedUrl['scheme']) && $parsedUrl['scheme'] === 'https') {
URL::forceScheme('https');
}
}
}
}
}

0
app/Providers/AuthServiceProvider.php Normal file → Executable file
View File

0
app/Providers/BroadcastServiceProvider.php Normal file → Executable file
View File

0
app/Providers/EventServiceProvider.php Normal file → Executable file
View File

0
app/Providers/RouteServiceProvider.php Normal file → Executable file
View File

View File

@@ -0,0 +1,288 @@
<?php
namespace App\Services;
use App\Models\Stock;
use App\Models\Work;
use App\Models\Transaction;
use App\Models\StockLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
class StockService
{
/**
* Check if dealer has sufficient stock for work
* Modified to always return available = true (allow negative stock)
*
* @param int $workId
* @param int $dealerId
* @param int $workQuantity
* @return array
*/
public function checkStockAvailability($workId, $dealerId, $workQuantity = 1)
{
$work = Work::with('products')->find($workId);
if (!$work) {
return [
'available' => true,
'message' => 'Pekerjaan tidak ditemukan, tapi transaksi diizinkan',
'details' => []
];
}
$stockDetails = [];
foreach ($work->products as $product) {
$requiredQuantity = $product->pivot->quantity_required * $workQuantity;
$availableStock = $product->getStockByDealer($dealerId);
$stockDetails[] = [
'product_id' => $product->id,
'product_name' => $product->name,
'required_quantity' => $requiredQuantity,
'available_stock' => $availableStock,
'is_available' => true // Always true - allow negative stock
];
}
return [
'available' => true, // Always return true - allow negative stock
'message' => 'Stock tersedia (negative stock allowed)',
'details' => $stockDetails
];
}
/**
* Reduce stock when work transaction is completed
*
* @param Transaction $transaction
* @return bool
* @throws Exception
*/
public function reduceStockForTransaction(Transaction $transaction)
{
try {
return DB::transaction(function () use ($transaction) {
$work = $transaction->work;
if (!$work) {
// If work not found, just return true to allow transaction to proceed
return true;
}
$work->load('products');
if ($work->products->isEmpty()) {
// No products required for this work, return true
return true;
}
foreach ($work->products as $product) {
$requiredQuantity = $product->pivot->quantity_required * $transaction->qty;
Log::info('Processing stock reduction', [
'transaction_id' => $transaction->id,
'product_id' => $product->id,
'product_name' => $product->name,
'dealer_id' => $transaction->dealer_id,
'required_quantity' => $requiredQuantity,
'transaction_qty' => $transaction->qty
]);
$stock = Stock::where('product_id', $product->id)
->where('dealer_id', $transaction->dealer_id)
->first();
if (!$stock) {
Log::info('Stock not found, creating new stock record', [
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id
]);
try {
// Create new stock record with 0 quantity if doesn't exist
$stock = Stock::create([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id,
'quantity' => 0
]);
Log::info('New stock record created', [
'stock_id' => $stock->id,
'initial_quantity' => $stock->quantity
]);
} catch (\Exception $createException) {
Log::warning('Failed to create stock, using firstOrCreate', [
'error' => $createException->getMessage()
]);
// If creating stock fails, try to use firstOrCreate instead
$stock = Stock::firstOrCreate([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id
], [
'quantity' => 0
]);
}
} else {
Log::info('Existing stock found', [
'stock_id' => $stock->id,
'current_quantity' => $stock->quantity
]);
}
// Allow negative stock - reduce regardless of current quantity
$newQuantity = $stock->quantity - $requiredQuantity;
Log::info('Updating stock quantity', [
'stock_id' => $stock->id,
'previous_quantity' => $stock->quantity,
'required_quantity' => $requiredQuantity,
'new_quantity' => $newQuantity
]);
try {
$stock->updateStock(
$newQuantity,
$transaction,
"Stock reduced for work: {$work->name} (Transaction #{$transaction->id}) - Allow negative stock"
);
Log::info('Stock update successful via updateStock method');
} catch (\Exception $updateException) {
Log::warning('updateStock method failed, using fallback', [
'error' => $updateException->getMessage()
]);
// If updateStock fails, try direct update but still create stock log
$previousQuantity = $stock->quantity;
$stock->quantity = $newQuantity;
$stock->save();
// Manually create stock log since updateStock failed
try {
$stockLog = \App\Models\StockLog::create([
'stock_id' => $stock->id,
'source_type' => get_class($transaction),
'source_id' => $transaction->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity,
'quantity_change' => $newQuantity - $previousQuantity,
'description' => "Stock reduced for work: {$work->name} (Transaction #{$transaction->id}) - Allow negative stock (manual log)",
'user_id' => auth()->id()
]);
Log::info('Manual stock log created successfully', [
'stock_log_id' => $stockLog->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity
]);
} catch (\Exception $logException) {
// Log the error but don't fail the transaction
Log::warning('Failed to create stock log: ' . $logException->getMessage());
}
}
}
return true;
});
} catch (\Exception $e) {
// Log the error but don't throw it - allow transaction to proceed
Log::error('StockService::reduceStockForTransaction error: ' . $e->getMessage(), [
'transaction_id' => $transaction->id,
'work_id' => $transaction->work_id,
'dealer_id' => $transaction->dealer_id
]);
// Return true to allow transaction to proceed even if stock reduction fails
return true;
}
}
/**
* Restore stock when work transaction is cancelled/reversed
*
* @param Transaction $transaction
* @return bool
* @throws Exception
*/
public function restoreStockForTransaction(Transaction $transaction)
{
return DB::transaction(function () use ($transaction) {
$work = $transaction->work;
if (!$work) {
throw new Exception('Work not found for transaction');
}
$work->load('products');
if ($work->products->isEmpty()) {
return true;
}
foreach ($work->products as $product) {
$restoreQuantity = $product->pivot->quantity_required * $transaction->qty;
$stock = Stock::where('product_id', $product->id)
->where('dealer_id', $transaction->dealer_id)
->first();
if (!$stock) {
// Create new stock record if doesn't exist
$stock = Stock::create([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id,
'quantity' => 0
]);
}
// Restore stock
$newQuantity = $stock->quantity + $restoreQuantity;
$stock->updateStock(
$newQuantity,
$transaction,
"Stock restored from cancelled work: {$work->name} (Transaction #{$transaction->id})"
);
}
return true;
});
}
/**
* Get stock usage prediction for a work
*
* @param int $workId
* @param int $quantity
* @return array
*/
public function getStockUsagePrediction($workId, $quantity = 1)
{
$work = Work::with('products')->find($workId);
if (!$work) {
return [];
}
$predictions = [];
foreach ($work->products as $product) {
$totalRequired = $product->pivot->quantity_required * $quantity;
$predictions[] = [
'product_id' => $product->id,
'product_name' => $product->name,
'product_code' => $product->code,
'unit' => $product->unit,
'quantity_per_work' => $product->pivot->quantity_required,
'total_quantity_needed' => $totalRequired,
'notes' => $product->pivot->notes
];
}
return $predictions;
}
}

0
artisan Normal file → Executable file
View File

253
backup_advanced.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/bin/bash
# Advanced Database Backup Script for CKB Docker
# ===============================================
# Configuration
CONTAINER_NAME="ckb-mysql-dev"
DB_NAME="ckb_db"
DB_USER="root"
DB_PASSWORD="root"
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=30 # Keep backups for 30 days
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Functions
print_header() {
echo -e "${BLUE}================================================${NC}"
echo -e "${BLUE} CKB Database Advanced Backup Tool${NC}"
echo -e "${BLUE}================================================${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
check_container() {
if ! docker ps | grep -q $CONTAINER_NAME; then
print_error "Container $CONTAINER_NAME is not running!"
exit 1
fi
print_success "Container $CONTAINER_NAME is running"
}
create_backup_dir() {
mkdir -p $BACKUP_DIR
print_info "Backup directory: $BACKUP_DIR"
}
cleanup_old_backups() {
print_info "Cleaning up backups older than $RETENTION_DAYS days..."
# Find and delete old backup files
old_files=$(find $BACKUP_DIR -name "ckb_backup_*.sql*" -mtime +$RETENTION_DAYS 2>/dev/null)
if [[ -z "$old_files" ]]; then
print_info "No old backup files found"
else
echo "$old_files" | while read file; do
rm -f "$file"
print_warning "Deleted old backup: $(basename "$file")"
done
fi
}
backup_full() {
local backup_file="$BACKUP_DIR/ckb_backup_${DATE}.sql"
print_info "Creating full backup..."
if docker exec $CONTAINER_NAME mysqldump -u $DB_USER -p$DB_PASSWORD \
--single-transaction \
--routines \
--triggers \
--add-drop-table \
$DB_NAME > "$backup_file"; then
print_success "Full backup created: $backup_file"
return 0
else
print_error "Full backup failed!"
return 1
fi
}
backup_structure_only() {
local backup_file="$BACKUP_DIR/ckb_structure_${DATE}.sql"
print_info "Creating structure-only backup..."
if docker exec $CONTAINER_NAME mysqldump -u $DB_USER -p$DB_PASSWORD \
--no-data \
--routines \
--triggers \
$DB_NAME > "$backup_file"; then
print_success "Structure backup created: $backup_file"
return 0
else
print_error "Structure backup failed!"
return 1
fi
}
backup_data_only() {
local backup_file="$BACKUP_DIR/ckb_data_${DATE}.sql"
print_info "Creating data-only backup..."
if docker exec $CONTAINER_NAME mysqldump -u $DB_USER -p$DB_PASSWORD \
--no-create-info \
--single-transaction \
$DB_NAME > "$backup_file"; then
print_success "Data backup created: $backup_file"
return 0
else
print_error "Data backup failed!"
return 1
fi
}
compress_backup() {
local file="$1"
if [[ -f "$file" ]]; then
print_info "Compressing backup..."
if gzip "$file"; then
print_success "Backup compressed: $file.gz"
# Show compression ratio
original_size=$(stat -c%s "$file" 2>/dev/null || echo "0")
compressed_size=$(stat -c%s "$file.gz" 2>/dev/null || echo "0")
if [[ $original_size -gt 0 ]]; then
ratio=$(echo "scale=1; (1 - $compressed_size/$original_size) * 100" | bc -l 2>/dev/null || echo "0")
print_info "Compression ratio: ${ratio}%"
fi
else
print_error "Compression failed!"
fi
fi
}
show_backup_stats() {
print_info "Backup Statistics:"
echo "----------------------------------------"
# Count backup files
total_backups=$(ls -1 $BACKUP_DIR/ckb_backup_*.sql* 2>/dev/null | wc -l)
total_size=$(du -sh $BACKUP_DIR 2>/dev/null | cut -f1)
echo "Total backups: $total_backups"
echo "Total size: $total_size"
echo "Newest backup: $(ls -1t $BACKUP_DIR/ckb_backup_*.sql* 2>/dev/null | head -1 | xargs basename 2>/dev/null || echo "None")"
echo "Oldest backup: $(ls -1tr $BACKUP_DIR/ckb_backup_*.sql* 2>/dev/null | head -1 | xargs basename 2>/dev/null || echo "None")"
}
# Main Script
print_header
echo "Backup Options:"
echo "1. Full backup (structure + data)"
echo "2. Structure only backup"
echo "3. Data only backup"
echo "4. All backup types"
echo "5. Show backup statistics"
echo "6. Cleanup old backups"
echo "7. Exit"
echo
read -p "Choose option (1-7): " -n 1 -r option
echo
case $option in
1)
check_container
create_backup_dir
if backup_full; then
backup_file="$BACKUP_DIR/ckb_backup_${DATE}.sql"
read -p "Compress backup? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
compress_backup "$backup_file"
fi
cleanup_old_backups
show_backup_stats
fi
;;
2)
check_container
create_backup_dir
if backup_structure_only; then
backup_file="$BACKUP_DIR/ckb_structure_${DATE}.sql"
read -p "Compress backup? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
compress_backup "$backup_file"
fi
fi
;;
3)
check_container
create_backup_dir
if backup_data_only; then
backup_file="$BACKUP_DIR/ckb_data_${DATE}.sql"
read -p "Compress backup? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
compress_backup "$backup_file"
fi
fi
;;
4)
check_container
create_backup_dir
print_info "Creating all backup types..."
backup_full && compress_backup "$BACKUP_DIR/ckb_backup_${DATE}.sql"
backup_structure_only && compress_backup "$BACKUP_DIR/ckb_structure_${DATE}.sql"
backup_data_only && compress_backup "$BACKUP_DIR/ckb_data_${DATE}.sql"
cleanup_old_backups
show_backup_stats
;;
5)
create_backup_dir
show_backup_stats
;;
6)
create_backup_dir
cleanup_old_backups
show_backup_stats
;;
7)
print_info "Goodbye!"
exit 0
;;
*)
print_error "Invalid option!"
exit 1
;;
esac
print_success "Backup process completed!"

48
backup_db.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Configuration
CONTAINER_NAME="ckb-mysql-dev"
DB_NAME="ckb_db"
DB_USER="root"
DB_PASSWORD="root"
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="ckb_backup_${DATE}.sql"
# Create backup directory if it doesn't exist
mkdir -p $BACKUP_DIR
# Check if container is running
if ! docker ps | grep -q $CONTAINER_NAME; then
echo "Error: Container $CONTAINER_NAME is not running!"
exit 1
fi
echo "Starting database backup..."
echo "Container: $CONTAINER_NAME"
echo "Database: $DB_NAME"
echo "Backup file: $BACKUP_DIR/$BACKUP_FILE"
# Create backup
if docker exec $CONTAINER_NAME mysqldump -u $DB_USER -p$DB_PASSWORD $DB_NAME > "$BACKUP_DIR/$BACKUP_FILE"; then
echo "✅ Backup completed successfully!"
echo "📁 File saved: $BACKUP_DIR/$BACKUP_FILE"
# Show file size
FILE_SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
echo "📊 File size: $FILE_SIZE"
# Optional: Compress the backup
read -p "Do you want to compress the backup? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
gzip "$BACKUP_DIR/$BACKUP_FILE"
echo "🗜️ Backup compressed: $BACKUP_DIR/$BACKUP_FILE.gz"
fi
else
echo "❌ Backup failed!"
exit 1
fi
echo "🎉 Backup process completed!"

0
bengkell.zip Normal file → Executable file
View File

0
bootstrap/app.php Normal file → Executable file
View File

0
bootstrap/cache/.gitignore vendored Normal file → Executable file
View File

745
ckb.sql Executable file

File diff suppressed because one or more lines are too long

11
composer.json Normal file → Executable file
View File

@@ -2,7 +2,10 @@
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"require": {
"php": "^7.3|^8.0",
@@ -14,7 +17,8 @@
"laravel/tinker": "^2.5",
"laravel/ui": "^3.4",
"maatwebsite/excel": "^3.1",
"yajra/laravel-datatables-oracle": "^9.20"
"nesbot/carbon": "^2.73",
"yajra/laravel-datatables-oracle": "^9.21"
},
"require-dev": {
"facade/ignition": "^2.5",
@@ -50,9 +54,10 @@
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
],
"dev": "npm run development",
"dev": "npx concurrently \"php artisan serve\" \"npm run hot\"",
"development": "npx mix",
"watch": "npx mix watch",
"hot": "npx mix watch --hot",
"production": "npx mix --production"
},
"extra": {

197
composer.lock generated Normal file → Executable file
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": "f96a1847e52c9eb29681f0fb8fea48c2",
"content-hash": "a73100beed847d2c43aca4cca10a0d86",
"packages": [
{
"name": "asm89/stack-cors",
@@ -122,6 +122,75 @@
],
"time": "2021-08-15T20:50:18+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
"reference": "3c430083d0b41ceed84ecccf9dac613241d7305d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/3c430083d0b41ceed84ecccf9dac613241d7305d",
"reference": "3c430083d0b41ceed84ecccf9dac613241d7305d",
"shasum": ""
},
"require": {
"php": "^7.1.8 || ^8.0"
},
"conflict": {
"doctrine/dbal": ">=3.7.0"
},
"require-dev": {
"doctrine/dbal": ">=2.0.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "KyleKatarn",
"email": "kylekatarnls@gmail.com"
}
],
"description": "Types to use Carbon in Doctrine",
"keywords": [
"carbon",
"date",
"datetime",
"doctrine",
"time"
],
"support": {
"issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
"source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/1.0.0"
},
"funding": [
{
"url": "https://github.com/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon",
"type": "open_collective"
},
{
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
"type": "tidelift"
}
],
"time": "2023-10-01T12:35:29+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.1",
@@ -2537,35 +2606,41 @@
},
{
"name": "nesbot/carbon",
"version": "2.58.0",
"version": "2.73.0",
"source": {
"type": "git",
"url": "https://github.com/briannesbitt/Carbon.git",
"reference": "97a34af22bde8d0ac20ab34b29d7bfe360902055"
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/97a34af22bde8d0ac20ab34b29d7bfe360902055",
"reference": "97a34af22bde8d0ac20ab34b29d7bfe360902055",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/9228ce90e1035ff2f0db84b40ec2e023ed802075",
"reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075",
"shasum": ""
},
"require": {
"carbonphp/carbon-doctrine-types": "*",
"ext-json": "*",
"php": "^7.1.8 || ^8.0",
"psr/clock": "^1.0",
"symfony/polyfill-mbstring": "^1.0",
"symfony/polyfill-php80": "^1.16",
"symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0"
},
"provide": {
"psr/clock-implementation": "1.0"
},
"require-dev": {
"doctrine/dbal": "^2.0 || ^3.0",
"doctrine/orm": "^2.7",
"doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0",
"doctrine/orm": "^2.7 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.0",
"kylekatarnls/multi-tester": "^2.0",
"ondrejmirtes/better-reflection": "<6",
"phpmd/phpmd": "^2.9",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.12.54 || ^1.0",
"phpunit/php-file-iterator": "^2.0.5",
"phpunit/phpunit": "^7.5.20 || ^8.5.23",
"phpstan/phpstan": "^0.12.99 || ^1.7.14",
"phpunit/php-file-iterator": "^2.0.5 || ^3.0.6",
"phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20",
"squizlabs/php_codesniffer": "^3.4"
},
"bin": [
@@ -2573,10 +2648,6 @@
],
"type": "library",
"extra": {
"branch-alias": {
"dev-3.x": "3.x-dev",
"dev-master": "2.x-dev"
},
"laravel": {
"providers": [
"Carbon\\Laravel\\ServiceProvider"
@@ -2586,6 +2657,10 @@
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-2.x": "2.x-dev",
"dev-master": "3.x-dev"
}
},
"autoload": {
@@ -2622,15 +2697,19 @@
},
"funding": [
{
"url": "https://opencollective.com/Carbon",
"type": "open_collective"
"url": "https://github.com/sponsors/kylekatarnls",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
"url": "https://opencollective.com/Carbon#sponsor",
"type": "opencollective"
},
{
"url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
"type": "tidelift"
}
],
"time": "2022-04-25T19:31:17+00:00"
"time": "2025-01-08T20:10:23+00:00"
},
{
"name": "nette/schema",
@@ -3124,6 +3203,54 @@
},
"time": "2016-08-06T20:24:11+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/clock.git",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Psr\\Clock\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for reading the clock.",
"homepage": "https://github.com/php-fig/clock",
"keywords": [
"clock",
"now",
"psr",
"psr-20",
"time"
],
"support": {
"issues": "https://github.com/php-fig/clock/issues",
"source": "https://github.com/php-fig/clock/tree/1.0.0"
},
"time": "2022-11-25T14:36:26+00:00"
},
{
"name": "psr/container",
"version": "1.1.2",
@@ -6323,16 +6450,16 @@
},
{
"name": "yajra/laravel-datatables-oracle",
"version": "v9.20.0",
"version": "v9.21.2",
"source": {
"type": "git",
"url": "https://github.com/yajra/laravel-datatables.git",
"reference": "4c22b09c8c664df5aad9f17d99c3823c0f2d84e2"
"reference": "a7fd01f06282923e9c63fa27fe6b391e21dc321a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/4c22b09c8c664df5aad9f17d99c3823c0f2d84e2",
"reference": "4c22b09c8c664df5aad9f17d99c3823c0f2d84e2",
"url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/a7fd01f06282923e9c63fa27fe6b391e21dc321a",
"reference": "a7fd01f06282923e9c63fa27fe6b391e21dc321a",
"shasum": ""
},
"require": {
@@ -6354,16 +6481,16 @@
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "9.0-dev"
},
"laravel": {
"providers": [
"Yajra\\DataTables\\DataTablesServiceProvider"
],
"aliases": {
"DataTables": "Yajra\\DataTables\\Facades\\DataTables"
}
},
"providers": [
"Yajra\\DataTables\\DataTablesServiceProvider"
]
},
"branch-alias": {
"dev-master": "9.0-dev"
}
},
"autoload": {
@@ -6392,7 +6519,7 @@
],
"support": {
"issues": "https://github.com/yajra/laravel-datatables/issues",
"source": "https://github.com/yajra/laravel-datatables/tree/v9.20.0"
"source": "https://github.com/yajra/laravel-datatables/tree/v9.21.2"
},
"funding": [
{
@@ -6404,7 +6531,7 @@
"type": "patreon"
}
],
"time": "2022-05-08T16:04:16+00:00"
"time": "2022-07-12T04:48:03+00:00"
}
],
"packages-dev": [
@@ -8917,12 +9044,12 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^7.3|^8.0"
},
"platform-dev": [],
"plugin-api-version": "2.1.0"
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

0
config/app.php Normal file → Executable file
View File

0
config/auth.php Normal file → Executable file
View File

0
config/broadcasting.php Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More