feat(bucok)!: tambah modul Bucok end-to-end + impor updateOrCreate

- Tambah routing, breadcrumbs, menu, dan views (index + detail)
- Controller: index/show, datatables (filter multi-kolom, sorting, pagination), impor Excel (transaksi + logging)
- Import: updateOrCreate by nomor_tiket, normalisasi tanggal & numerik, statistik impor
- Migrasi: semua kolom bisnis → string untuk konsistensi input Excel; nomor_tiket unique + index
- UX: DataTable dengan filter (tahun, bulan, cost center, status), tombol import, detail tiket

BREAKING CHANGE:
- Semua kolom bisnis kini bertipe string → perlu sesuaikan casts di model Bucok & filter tanggal/numerik di controller
This commit is contained in:
Daeng Deni Mardaeni
2025-08-19 11:30:19 +07:00
parent 19fb39b02f
commit 32baffe636
9 changed files with 1381 additions and 45 deletions

View File

@@ -0,0 +1,209 @@
<?php
namespace Modules\Lpj\Http\Controllers;
use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Lpj\Imports\BucokImport;
use Modules\Lpj\Models\Bucok;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Controller untuk mengelola data Bucok
*
* @package Modules\Lpj\Http\Controllers
*/
class BucokController extends Controller
{
public $user;
/**
* Menampilkan halaman index bucok
*
* @return \Illuminate\View\View
*/
public function index()
{
return view('lpj::bucok.index');
}
/**
* Menampilkan detail bucok
*
* @param int $id
* @return \Illuminate\View\View
*/
public function show($id)
{
$bucok = Bucok::findOrFail($id);
return view('lpj::bucok.show', compact('bucok'));
}
/**
* Data untuk datatables dengan server-side processing
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function dataForDatatables(Request $request)
{
if (is_null($this->user) || !$this->user->can('bucok.view')) {
// abort(403, 'Sorry! You are not allowed to view bucok.');
}
// Retrieve data from the database
$query = Bucok::query();
// Apply search filter if provided
if ($request->has('search') && !empty($request->get('search'))) {
$search = $request->get('search');
$query->where(function ($q) use ($search) {
$q->where('nomor_tiket', 'LIKE', "%$search%")
->orWhere('deskripsi', 'LIKE', "%$search%")
->orWhere('nomor_coa', 'LIKE', "%$search%")
->orWhere('nama_coa', 'LIKE', "%$search%")
->orWhere('cost_center', 'LIKE', "%$search%")
->orWhere('nama_sub_direktorat', 'LIKE', "%$search%")
->orWhere('nama_direktorat_cabang', 'LIKE', "%$search%")
->orWhere('penyelesaian', 'LIKE', "%$search%")
->orWhere('keterangan_gantung', 'LIKE', "%$search%");
});
}
// Apply date range filter
if ($request->has('start_date') && !empty($request->get('start_date'))) {
$query->whereDate('tanggal', '>=', $request->get('start_date'));
}
if ($request->has('end_date') && !empty($request->get('end_date'))) {
$query->whereDate('tanggal', '<=', $request->get('end_date'));
}
// Apply year filter
if ($request->has('year') && !empty($request->get('year'))) {
$query->byYear($request->get('year'));
}
// Apply month filter
if ($request->has('month') && !empty($request->get('month'))) {
$query->byMonth($request->get('month'));
}
// Apply cost center filter
if ($request->has('cost_center') && !empty($request->get('cost_center'))) {
$query->byCostCenter($request->get('cost_center'));
}
// Apply completion status filter
if ($request->has('completion_status') && $request->get('completion_status') !== '') {
$isCompleted = $request->get('completion_status') == '1';
$query->byCompletionStatus($isCompleted);
}
// Apply sorting if provided
if ($request->has('sortOrder') && !empty($request->get('sortOrder'))) {
$order = $request->get('sortOrder');
$column = $request->get('sortField', 'created_at');
$query->orderBy($column, $order);
} else {
$query->orderBy('created_at', 'desc');
}
// Get the total count of records
$totalRecords = $query->count();
// Apply pagination if provided
if ($request->has('page') && $request->has('size')) {
$page = $request->get('page');
$size = $request->get('size');
$offset = ($page - 1) * $size; // Calculate the offset
$query->skip($offset)->take($size);
}
// Get the filtered count of records
$filteredRecords = $query->count();
// Get the data for the current page with relationships
$data = $query->get();
// Transform data untuk datatables
$transformedData = $data->map(function ($item) {
return [
'id' => $item->id,
'no' => $item->no,
'tanggal' => $item->tanggal ? dateFormat($item->tanggal,true) : '-',
'bulan' => $item->bulan,
'tahun' => $item->tahun,
'nomor_tiket' => $item->nomor_tiket,
'nomor_coa' => $item->nomor_coa,
'nama_coa' => $item->nama_coa,
'deskripsi' => $item->deskripsi,
'nominal' => $item->nominal_formatted,
'penyelesaian' => $item->penyelesaian,
'umur_aging' => $item->umur_aging,
'cost_center' => $item->cost_center,
'nama_sub_direktorat' => $item->nama_sub_direktorat,
'nama_direktorat_cabang' => $item->nama_direktorat_cabang,
'tanggal_penyelesaian' => $item->tanggal_penyelesaian ? dateFormat($item->tanggal_penyelesaian,true) : '-',
'nominal_penyelesaian' => $item->nominal_penyelesaian_formatted,
'status_penyelesaian' => $item->status_penyelesaian,
'status_badge' => $item->status_badge,
'created_by' => $item->creator?->name ?? '-',
'created_at' => dateFormat($item->created_at,true)
];
});
// Calculate the page count
$pageCount = ceil($totalRecords / ($request->get('size', 10)));
// Calculate the current page number
$currentPage = $request->get('page', 1);
// Return the response data as a JSON object
return response()->json([
'draw' => $request->get('draw'),
'recordsTotal' => $totalRecords,
'recordsFiltered' => $filteredRecords,
'pageCount' => $pageCount,
'page' => $currentPage,
'totalCount' => $totalRecords,
'data' => $transformedData,
]);
}
/**
* Import data bucok dari Excel
*
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function import(Request $request)
{
$request->validate([
'file' => 'required|mimes:xlsx,xls,csv|max:10240' // Max 10MB
]);
DB::beginTransaction();
try {
Log::info('Importing Bucok data', ['user_id' => Auth::id(), 'filename' => $request->file('file')->getClientOriginalName()]);
$import = new BucokImport();
Excel::import($import, $request->file('file'));
$statistics = $import->getImportStatistics();
DB::commit();
Log::info('Bucok data imported successfully', ['user_id' => Auth::id()]);
return redirect()->back()->with('success', 'Data Bucok berhasil diimport. Total: ' . $statistics['total_processed'] . ', Created: ' . $statistics['created'] . ', Updated: ' . $statistics['updated'] . ', Skipped: ' . $statistics['skipped'] . ', Errors: ' . $statistics['errors']);
} catch (Exception $e) {
DB::rollback();
Log::error('Failed to import Bucok data', ['error' => $e->getMessage(), 'user_id' => Auth::id()]);
return redirect()->back()->with('error', 'Gagal import data: ' . $e->getMessage());
}
}
}

341
app/Imports/BucokImport.php Normal file
View File

@@ -0,0 +1,341 @@
<?php
namespace Modules\Lpj\Imports;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithStartRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithBatchInserts;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Modules\Lpj\Models\Bucok;
use Carbon\Carbon;
use Exception;
/**
* Kelas untuk mengimpor data Excel ke tabel bucoks
* Menggunakan Laravel Excel dengan validasi dan batch processing
* Data dimulai dari baris ke-5 tanpa header
*/
class BucokImport implements ToCollection, WithStartRow, WithValidation, WithBatchInserts, WithChunkReading
{
private $importedCount = 0;
private $skippedCount = 0;
private $createdCount = 0;
private $updatedCount = 0;
private $errors = [];
/**
* Menentukan baris mulai membaca data (baris ke-5)
*
* @return int
*/
public function startRow(): int
{
return 5;
}
/**
* Memproses koleksi data dari Excel
*
* @param Collection $collection
* @return void
*/
public function collection(Collection $collection)
{
DB::beginTransaction();
try {
foreach ($collection as $rowIndex => $row) {
// Log setiap baris yang diproses
Log::info('Processing Bucok import row', [
'row_number' => $rowIndex + 5, // +5 karena mulai dari baris 5
'row_data' => $row->toArray()
]);
// Konversi row ke array dengan indeks numerik
$rowArray = $row->toArray();
// Skip baris kosong
if (empty(array_filter($rowArray))) {
continue;
}
// Validasi data baris
$mappedData = $this->mapRowToBucok($rowArray, $rowIndex + 5);
// Update atau create berdasarkan nomor_tiket
if (!empty($mappedData['nomor_tiket'])) {
// Update atau create berdasarkan nomor_tiket
$bucok = Bucok::updateOrCreate(
['nomor_tiket' => $mappedData['nomor_tiket']], // Kondisi pencarian
array_merge($mappedData, ['updated_by' => auth()->id()]) // Data yang akan diupdate/create
);
// Log dan tracking apakah data di-update atau di-create
if ($bucok->wasRecentlyCreated) {
$this->createdCount++;
Log::info('Bucok created successfully', [
'row_number' => $rowIndex + 5,
'nomor_tiket' => $mappedData['nomor_tiket'],
'action' => 'created'
]);
} else {
$this->updatedCount++;
Log::info('Bucok updated successfully', [
'row_number' => $rowIndex + 5,
'nomor_tiket' => $mappedData['nomor_tiket'],
'action' => 'updated'
]);
}
}
$this->importedCount++;
}
DB::commit();
// Log summary
Log::info('Bucok import completed', [
'imported' => $this->importedCount,
'skipped' => $this->skippedCount,
'total_errors' => count($this->errors)
]);
} catch (Exception $e) {
DB::rollback();
Log::error('Bucok import failed', ['error' => $e->getMessage()]);
throw $e;
}
}
/**
* Mapping data Excel berdasarkan indeks kolom ke struktur model Bucok
* Kolom dimulai dari indeks 0 (A=0, B=1, C=2, dst.)
*
* @param array $row
* @param int $rowNumber
* @return array
*/
private function mapRowToBucok(array $row, int $rowNumber): array
{
return [
'no' => $row[0] ?? null, // Kolom A
'tanggal' => !empty($row[1]) ? $this->parseDate($row[1]) : null, // Kolom B
'bulan' => $row[2] ?? null, // Kolom C
'tahun' => $row[3] ?? null, // Kolom D
'tanggal_penuh' => !empty($row[4]) ? $this->parseDate($row[4]) : null, // Kolom E
'nomor_categ' => $row[5] ?? null, // Kolom F
'coa_summary' => $row[6] ?? null, // Kolom G
'nomor_coa' => $row[7] ?? null, // Kolom H
'nama_coa' => $row[8] ?? null, // Kolom I
'nomor_tiket' => $row[9] ?? null, // Kolom J - Auto-generate jika kosong
'deskripsi' => $row[10] ?? null, // Kolom K
'nominal' => $this->parseNumeric($row[11] ?? 0), // Kolom L
'penyelesaian' => $row[12] ?? 'Belum Selesai', // Kolom M
'umur_aging' => $this->parseNumeric($row[13] ?? 0), // Kolom N
'cost_center' => $row[14] ?? null, // Kolom O
'nama_sub_direktorat' => $row[15] ?? null, // Kolom P
'nama_direktorat_cabang' => $row[16] ?? null, // Kolom Q
'tanggal_penyelesaian' => !empty($row[17]) ? $this->parseDate($row[17]) : null, // Kolom R
'nominal_penyelesaian' => $this->parseNumeric($row[18] ?? 0), // Kolom S
'nominal_berjalan' => $this->parseNumeric($row[19] ?? 0), // Kolom T
'amortisasi_berjalan' => $this->parseNumeric($row[20] ?? 0), // Kolom U
'sistem_berjalan' => $this->parseNumeric($row[21] ?? 0), // Kolom V
'lainnya_berjalan' => $this->parseNumeric($row[22] ?? 0), // Kolom W
'nominal_gantung' => $this->parseNumeric($row[23] ?? 0), // Kolom X
'aset_gantung' => $this->parseNumeric($row[24] ?? 0), // Kolom Y
'keterangan_gantung' => $row[25] ?? null, // Kolom Z
'lainnya_satu' => $row[26] ?? null, // Kolom AA
'lainnya_dua' => $row[27] ?? null, // Kolom AB
'created_by' => auth()->id(),
'updated_by' => auth()->id()
];
}
/**
* Parse tanggal dari berbagai format
*
* @param mixed $dateValue
* @return Carbon|null
*/
private function parseDate($dateValue)
{
if (empty($dateValue)) {
return null;
}
try {
// Jika berupa angka Excel date serial
if (is_numeric($dateValue)) {
return Carbon::createFromFormat('Y-m-d', \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($dateValue)->format('Y-m-d'));
}
// Jika berupa string tanggal
return Carbon::parse($dateValue);
} catch (Exception $e) {
Log::warning('Failed to parse date', ['value' => $dateValue, 'error' => $e->getMessage()]);
return null;
}
}
/**
* Parse nilai numerik dari berbagai format
*
* @param mixed $numericValue
* @return float
*/
private function parseNumeric($numericValue): float
{
if (empty($numericValue)) {
return 0;
}
// Hapus karakter non-numerik kecuali titik dan koma
$cleaned = preg_replace('/[^0-9.,\-]/', '', $numericValue);
// Ganti koma dengan titik untuk decimal
$cleaned = str_replace(',', '.', $cleaned);
return (float) $cleaned;
}
/**
* Validasi data yang sudah dimapping
*
* @param array $data
* @return \Illuminate\Validation\Validator
*/
private function validateMappedData(array $data)
{
return Validator::make($data, [
'no' => 'nullable|integer',
'tanggal' => 'nullable|date',
'bulan' => 'nullable|integer|between:1,12',
'tahun' => 'nullable|integer|min:2000|max:2099',
'tanggal_penuh' => 'nullable|date',
'nomor_categ' => 'nullable|string|max:50',
'coa_summary' => 'nullable|string|max:255',
'nomor_coa' => 'nullable|string|max:50',
'nama_coa' => 'nullable|string|max:255',
'nomor_tiket' => 'nullable|string|max:50',
'deskripsi' => 'nullable|string',
'nominal' => 'nullable|numeric|min:0',
'penyelesaian' => 'nullable|in:Selesai,Belum Selesai,Dalam Proses',
'umur_aging' => 'nullable|integer|min:0',
'cost_center' => 'nullable|string|max:100',
'nama_sub_direktorat' => 'nullable|string|max:255',
'nama_direktorat_cabang' => 'nullable|string|max:255',
'tanggal_penyelesaian' => 'nullable|date',
'nominal_penyelesaian' => 'nullable|numeric|min:0',
'nominal_berjalan' => 'nullable|numeric|min:0',
'amortisasi_berjalan' => 'nullable|numeric|min:0',
'sistem_berjalan' => 'nullable|numeric|min:0',
'lainnya_berjalan' => 'nullable|numeric|min:0',
'nominal_gantung' => 'nullable|numeric|min:0',
'aset_gantung' => 'nullable|numeric|min:0',
'keterangan_gantung' => 'nullable|string',
'lainnya_satu' => 'nullable|string',
'lainnya_dua' => 'nullable|string'
]);
}
/**
* Aturan validasi untuk seluruh file Excel (tidak digunakan karena tanpa header)
*
* @return array
*/
public function rules(): array
{
return [];
}
/**
* Ukuran batch untuk insert
*
* @return int
*/
public function batchSize(): int
{
return 100;
}
/**
* Ukuran chunk untuk membaca file
*
* @return int
*/
public function chunkSize(): int
{
return 100;
}
/**
* Mendapatkan jumlah data yang berhasil diimpor
*
* @return int
*/
public function getImportedCount(): int
{
return $this->importedCount;
}
/**
* Mendapatkan jumlah data yang berhasil dibuat
*
* @return int
*/
public function getCreatedCount(): int
{
return $this->createdCount;
}
/**
* Mendapatkan jumlah data yang berhasil diupdate
*
* @return int
*/
public function getUpdatedCount(): int
{
return $this->updatedCount;
}
/**
* Mendapatkan statistik lengkap import
*
* @return array
*/
public function getImportStatistics(): array
{
return [
'total_processed' => $this->importedCount,
'created' => $this->createdCount,
'updated' => $this->updatedCount,
'skipped' => $this->skippedCount,
'errors' => count($this->errors)
];
}
/**
* Mendapatkan jumlah data yang dilewati
*
* @return int
*/
public function getSkippedCount(): int
{
return $this->skippedCount;
}
/**
* Mendapatkan daftar error yang terjadi
*
* @return array
*/
public function getErrors(): array
{
return $this->errors;
}
}

113
app/Models/Bucok.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace Modules\Lpj\Models;
use Illuminate\Foundation\Auth\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;
/**
* Model Bucok untuk mengelola data bucok
*
* @property int $id
* @property int|null $no
* @property string|null $tanggal
* @property string|null $bulan
* @property int|null $tahun
* @property string|null $tanggal_penuh
* @property string|null $nomor_categ
* @property string|null $coa_summary
* @property string|null $nomor_coa
* @property string|null $nama_coa
* @property string $nomor_tiket
* @property string|null $deskripsi
* @property float|null $nominal
* @property string|null $penyelesaian
* @property int|null $umur_aging
* @property string|null $cost_center
* @property string|null $nama_sub_direktorat
* @property string|null $nama_direktorat_cabang
* @property string|null $tanggal_penyelesaian
* @property float|null $nominal_penyelesaian
* @property float|null $nominal_berjalan
* @property float|null $amortisasi_berjalan
* @property float|null $sistem_berjalan
* @property float|null $lainnya_berjalan
* @property float|null $nominal_gantung
* @property float|null $aset_gantung
* @property string|null $keterangan_gantung
* @property string|null $lainnya_satu
* @property string|null $lainnya_dua
*/
class Bucok extends Base
{
use HasFactory;
/**
* Nama tabel yang digunakan oleh model
*/
protected $table = 'bucoks';
/**
* Field yang dapat diisi secara mass assignment
*/
protected $fillable = [
'no',
'tanggal',
'bulan',
'tahun',
'tanggal_penuh',
'nomor_categ',
'coa_summary',
'nomor_coa',
'nama_coa',
'nomor_tiket',
'deskripsi',
'nominal',
'penyelesaian',
'umur_aging',
'cost_center',
'nama_sub_direktorat',
'nama_direktorat_cabang',
'tanggal_penyelesaian',
'nominal_penyelesaian',
'nominal_berjalan',
'amortisasi_berjalan',
'sistem_berjalan',
'lainnya_berjalan',
'nominal_gantung',
'aset_gantung',
'keterangan_gantung',
'lainnya_satu',
'lainnya_dua',
];
/**
* Casting tipe data untuk field tertentu
*/
protected $casts = [
'no' => 'integer',
'tanggal' => 'date',
'tahun' => 'integer',
'tanggal_penuh' => 'date',
'nominal' => 'decimal:2',
'umur_aging' => 'integer',
'tanggal_penyelesaian' => 'date',
'nominal_penyelesaian' => 'decimal:2',
'nominal_berjalan' => 'decimal:2',
'amortisasi_berjalan' => 'decimal:2',
'sistem_berjalan' => 'decimal:2',
'lainnya_berjalan' => 'decimal:2',
'nominal_gantung' => 'decimal:2',
'aset_gantung' => 'decimal:2',
];
/**
* Field yang akan di-hidden saat serialization
*/
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
];
}