Compare commits
47 Commits
main
...
feat/stock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5daafc8f0 | ||
|
|
e96ca0a83c | ||
|
|
c3233ea6b2 | ||
|
|
33502e905d | ||
|
|
41ae7da60e | ||
|
|
334b9acd87 | ||
|
|
0de5bec84a | ||
|
|
82f9d7f466 | ||
|
|
e478dc81bb | ||
|
|
22477b6dab | ||
|
|
b803068d0e | ||
|
|
aa233eb793 | ||
|
|
567e4aa5fc | ||
|
|
9cfb566aee | ||
|
|
3fb598ae4d | ||
|
|
e9566d4c8a | ||
|
|
4517f7efcb | ||
|
|
ec8224760e | ||
|
|
ac55ed1b67 | ||
|
|
6625baf7bd | ||
|
|
2f5eff9e63 | ||
|
|
b2bfd666a7 | ||
|
|
680eb2045a | ||
|
|
ca7a0b941e | ||
|
|
e64cf43390 | ||
|
|
bba37c1720 | ||
|
|
520c0e9885 | ||
|
|
2fa60c583a | ||
|
|
b04b8f88cb | ||
|
|
58578532cc | ||
|
|
1a01efb1b5 | ||
|
|
a5e1348436 | ||
|
|
0b211915f1 | ||
|
|
647aa51187 | ||
|
|
9b25a772a6 | ||
|
|
f92655e3e2 | ||
|
|
84fb7ffb52 | ||
|
|
51079aa567 | ||
|
|
1a2ddb59d4 | ||
|
|
d294bb7876 | ||
|
|
ce0a4718e0 | ||
|
|
ff498cd98f | ||
|
|
8305e44c42 | ||
|
|
215792ce63 | ||
|
|
a881779c4f | ||
|
|
6bf8bc4965 | ||
|
|
59e23ae535 |
52
.dockerignore
Executable file
52
.dockerignore
Executable 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
0
.editorconfig
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.gitattributes
vendored
Normal file → Executable file
0
.gitattributes
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.styleci.yml
Normal file → Executable file
0
.styleci.yml
Normal file → Executable file
356
BACKUP_README.md
Executable file
356
BACKUP_README.md
Executable 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
169
DATABASE-IMPORT-GUIDE.md
Executable 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`**
|
||||
404
DOCKER-README.md
Executable file
404
DOCKER-README.md
Executable 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
85
Dockerfile
Executable 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
89
Dockerfile.dev
Executable 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
276
ENVIRONMENT-SETUP.md
Executable 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
225
PERMISSION-FIX-GUIDE.md
Executable 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
360
PRODUCTION-DEPLOYMENT.md
Executable 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!
|
||||
277
REDIS-FIX-GUIDE.md
Executable file
277
REDIS-FIX-GUIDE.md
Executable 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
0
api_bengkel2/git.zip
Normal file → Executable file
97
app/Console/Commands/CleanMutationsData.php
Normal file
97
app/Console/Commands/CleanMutationsData.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
app/Console/Commands/ClearMutationsCommand.php
Executable file
112
app/Console/Commands/ClearMutationsCommand.php
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/Console/Commands/ClearOpnameData.php
Executable file
93
app/Console/Commands/ClearOpnameData.php
Executable 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
|
||||
}
|
||||
}
|
||||
83
app/Console/Commands/SetupStockAuditMenu.php
Normal file
83
app/Console/Commands/SetupStockAuditMenu.php
Normal 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
4
app/Console/Kernel.php
Normal file → Executable 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
59
app/Enums/MutationStatus.php
Executable 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
55
app/Enums/OpnameStatus.php
Executable 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
21
app/Enums/StockChangeType.php
Executable 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
0
app/Exceptions/Handler.php
Normal file → Executable file
201
app/Exports/ProductStockDealers.php
Normal file
201
app/Exports/ProductStockDealers.php
Normal 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
0
app/Exports/TransactionDealerExport.php
Normal file → Executable file
0
app/Exports/TransactionExport.php
Normal file → Executable file
0
app/Exports/TransactionExport.php
Normal file → Executable file
0
app/Exports/TransactionSaExport.php
Normal file → Executable file
0
app/Exports/TransactionSaExport.php
Normal file → Executable file
0
app/Http/Controllers/AdminController.php
Normal file → Executable file
0
app/Http/Controllers/AdminController.php
Normal file → Executable file
0
app/Http/Controllers/ApiController.php
Normal file → Executable file
0
app/Http/Controllers/ApiController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ConfirmPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ConfirmPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ForgotPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ForgotPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/LoginController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/LoginController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/RegisterController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/RegisterController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ResetPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/ResetPasswordController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/VerificationController.php
Normal file → Executable file
0
app/Http/Controllers/Auth/VerificationController.php
Normal file → Executable file
12
app/Http/Controllers/CategoryController.php
Normal file → Executable file
12
app/Http/Controllers/CategoryController.php
Normal file → Executable 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
0
app/Http/Controllers/Controller.php
Normal file → Executable file
17
app/Http/Controllers/DealerController.php
Normal file → Executable file
17
app/Http/Controllers/DealerController.php
Normal file → Executable 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
0
app/Http/Controllers/HomeController.php
Normal file → Executable file
17
app/Http/Controllers/ReportController.php
Normal file → Executable file
17
app/Http/Controllers/ReportController.php
Normal file → Executable 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
0
app/Http/Controllers/RolePrivilegeController.php
Normal file → Executable file
333
app/Http/Controllers/TransactionController.php
Normal file → Executable file
333
app/Http/Controllers/TransactionController.php
Normal file → Executable 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
12
app/Http/Controllers/UserController.php
Normal file → Executable 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'])
|
||||
|
||||
553
app/Http/Controllers/WarehouseManagement/MutationsController.php
Executable file
553
app/Http/Controllers/WarehouseManagement/MutationsController.php
Executable 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
497
app/Http/Controllers/WarehouseManagement/OpnamesController.php
Executable file
497
app/Http/Controllers/WarehouseManagement/OpnamesController.php
Executable 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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
136
app/Http/Controllers/WarehouseManagement/ProductCategoriesController.php
Executable file
136
app/Http/Controllers/WarehouseManagement/ProductCategoriesController.php
Executable 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);
|
||||
}
|
||||
}
|
||||
288
app/Http/Controllers/WarehouseManagement/ProductsController.php
Executable file
288
app/Http/Controllers/WarehouseManagement/ProductsController.php
Executable 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?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.*');
|
||||
|
||||
// Filter berdasarkan dealer jika user bukan admin
|
||||
if (auth()->user()->dealer_id) {
|
||||
$data->whereHas('stock', function($query) {
|
||||
$query->where('dealer_id', auth()->user()->dealer_id);
|
||||
});
|
||||
}
|
||||
|
||||
// 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
22
app/Http/Controllers/WorkController.php
Normal file → Executable 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'])
|
||||
|
||||
247
app/Http/Controllers/WorkProductController.php
Normal file
247
app/Http/Controllers/WorkProductController.php
Normal 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
0
app/Http/Kernel.php
Normal file → Executable file
0
app/Http/Middleware/Authenticate.php
Normal file → Executable file
0
app/Http/Middleware/Authenticate.php
Normal file → Executable file
0
app/Http/Middleware/EncryptCookies.php
Normal file → Executable file
0
app/Http/Middleware/EncryptCookies.php
Normal file → Executable file
0
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file → Executable file
0
app/Http/Middleware/PreventRequestsDuringMaintenance.php
Normal file → Executable file
0
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file → Executable file
0
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file → Executable file
0
app/Http/Middleware/TrimStrings.php
Normal file → Executable file
0
app/Http/Middleware/TrimStrings.php
Normal file → Executable file
0
app/Http/Middleware/TrustHosts.php
Normal file → Executable file
0
app/Http/Middleware/TrustHosts.php
Normal file → Executable file
0
app/Http/Middleware/TrustProxies.php
Normal file → Executable file
0
app/Http/Middleware/TrustProxies.php
Normal file → Executable file
0
app/Http/Middleware/VerifyCsrfToken.php
Normal file → Executable file
0
app/Http/Middleware/VerifyCsrfToken.php
Normal file → Executable file
0
app/Http/Middleware/adminRole.php
Normal file → Executable file
0
app/Http/Middleware/adminRole.php
Normal file → Executable file
0
app/Http/Middleware/mechanicRole.php
Normal file → Executable file
0
app/Http/Middleware/mechanicRole.php
Normal file → Executable file
0
app/Models/Category.php
Normal file → Executable file
0
app/Models/Category.php
Normal file → Executable file
26
app/Models/Dealer.php
Normal file → Executable file
26
app/Models/Dealer.php
Normal file → Executable 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
0
app/Models/Menu.php
Normal file → Executable file
286
app/Models/Mutation.php
Executable file
286
app/Models/Mutation.php
Executable 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
116
app/Models/MutationDetail.php
Executable 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
119
app/Models/Opname.php
Executable 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
30
app/Models/OpnameDetail.php
Executable 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
0
app/Models/Privilege.php
Normal file → Executable file
72
app/Models/Product.php
Executable file
72
app/Models/Product.php
Executable 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
26
app/Models/ProductCategory.php
Executable 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
0
app/Models/Role.php
Normal file → Executable file
56
app/Models/Stock.php
Executable file
56
app/Models/Stock.php
Executable 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
70
app/Models/StockLog.php
Executable 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
34
app/Models/Transaction.php
Normal file → Executable 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
57
app/Models/User.php
Normal file → Executable 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
32
app/Models/Work.php
Normal file → Executable 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);
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Models/WorkProduct.php
Normal file
32
app/Models/WorkProduct.php
Normal 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
24
app/Providers/AppServiceProvider.php
Normal file → Executable 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
0
app/Providers/AuthServiceProvider.php
Normal file → Executable file
0
app/Providers/BroadcastServiceProvider.php
Normal file → Executable file
0
app/Providers/BroadcastServiceProvider.php
Normal file → Executable file
0
app/Providers/EventServiceProvider.php
Normal file → Executable file
0
app/Providers/EventServiceProvider.php
Normal file → Executable file
0
app/Providers/RouteServiceProvider.php
Normal file → Executable file
0
app/Providers/RouteServiceProvider.php
Normal file → Executable file
288
app/Services/StockService.php
Normal file
288
app/Services/StockService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
253
backup_advanced.sh
Executable file
253
backup_advanced.sh
Executable 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
48
backup_db.sh
Executable 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
0
bengkell.zip
Normal file → Executable file
0
bootstrap/app.php
Normal file → Executable file
0
bootstrap/app.php
Normal file → Executable file
0
bootstrap/cache/.gitignore
vendored
Normal file → Executable file
0
bootstrap/cache/.gitignore
vendored
Normal file → Executable file
11
composer.json
Normal file → Executable file
11
composer.json
Normal file → Executable 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
197
composer.lock
generated
Normal file → Executable 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
0
config/app.php
Normal file → Executable file
0
config/auth.php
Normal file → Executable file
0
config/auth.php
Normal file → Executable file
0
config/broadcasting.php
Normal file → Executable file
0
config/broadcasting.php
Normal file → Executable file
0
config/cache.php
Normal file → Executable file
0
config/cache.php
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user