first
This commit is contained in:
37
app/Http/Controllers/Admin/RateMasUploadController.php
Normal file
37
app/Http/Controllers/Admin/RateMasUploadController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\RateMasImportService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RateMasUploadController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.ratemas-upload');
|
||||
}
|
||||
|
||||
public function store(Request $request, RateMasImportService $importer): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'year' => ['required', 'integer', 'min:1900', 'max:2100'],
|
||||
'file' => ['required', 'file', 'mimes:csv,txt,xml', 'max:51200'],
|
||||
'replace_existing' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$result = $importer->import(
|
||||
(int) $validated['year'],
|
||||
$request->file('file')->getRealPath(),
|
||||
strtolower($request->file('file')->getClientOriginalExtension()),
|
||||
$request->boolean('replace_existing')
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.ratemas-upload.create')
|
||||
->with('status', "{$result['rows']} rekod berjaya dimasukkan ke table {$result['table']}.");
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/AuthController.php
Normal file
44
app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('tables.index'));
|
||||
}
|
||||
|
||||
return back()
|
||||
->withErrors(['username' => 'Login gagal. Sila semak username dan password.'])
|
||||
->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
114
app/Http/Controllers/RateMasController.php
Normal file
114
app/Http/Controllers/RateMasController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RateMasController extends Controller
|
||||
{
|
||||
private const TABLES = [
|
||||
'2013_ratemas' => 'RateMas 2013',
|
||||
'2014_ratemas' => 'RateMas 2014',
|
||||
'2015_ratemas' => 'RateMas 2015',
|
||||
'2016_ratemas' => 'RateMas 2016',
|
||||
'2017_ratemas' => 'RateMas 2017',
|
||||
'2018_ratemas' => 'RateMas 2018',
|
||||
];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
return view('ratemas.index', [
|
||||
'tables' => $this->availableTables(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function lookup(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'noakaun' => ['required', 'string', 'max:100'],
|
||||
'year' => ['required', 'integer', 'min:1900', 'max:2100'],
|
||||
]);
|
||||
|
||||
return redirect()->route('ratemas.show', [
|
||||
'table' => $validated['year'].'_ratemas',
|
||||
'noakaun' => $validated['noakaun'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(string $table): View
|
||||
{
|
||||
$this->abortIfInvalidTable($table);
|
||||
|
||||
return view('ratemas.search', [
|
||||
'table' => $table,
|
||||
'title' => $this->tableTitle($table),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $table): View
|
||||
{
|
||||
$this->abortIfInvalidTable($table);
|
||||
|
||||
$validated = $request->validate([
|
||||
'noakaun' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$record = DB::table($table)
|
||||
->where('no_akaun', $validated['noakaun'])
|
||||
->first();
|
||||
|
||||
abort_if($record === null, 404, 'Rekod no akaun tidak dijumpai.');
|
||||
|
||||
$years = array_map(
|
||||
fn (string $tableName): string => substr($tableName, 0, 4),
|
||||
array_keys($this->availableTables())
|
||||
);
|
||||
$currentYear = substr($table, 0, 4);
|
||||
$currentIndex = array_search($currentYear, $years, true);
|
||||
|
||||
return view('ratemas.show', [
|
||||
'record' => $record,
|
||||
'table' => $table,
|
||||
'title' => $this->tableTitle($table),
|
||||
'currentYear' => $currentYear,
|
||||
'years' => $years,
|
||||
'previousYear' => $currentIndex > 0 ? $years[$currentIndex - 1] : null,
|
||||
'nextYear' => $currentIndex < count($years) - 1 ? $years[$currentIndex + 1] : null,
|
||||
'noakaun' => $validated['noakaun'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function abortIfInvalidTable(string $table): void
|
||||
{
|
||||
abort_unless(preg_match('/^\d{4}_ratemas$/', $table) === 1 && Schema::hasTable($table), 404);
|
||||
}
|
||||
|
||||
private function tableTitle(string $table): string
|
||||
{
|
||||
return $this->availableTables()[$table] ?? 'RateMas '.substr($table, 0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function availableTables(): array
|
||||
{
|
||||
$tables = self::TABLES;
|
||||
|
||||
foreach (Schema::getTables() as $table) {
|
||||
$name = $table['name'] ?? $table['table'] ?? null;
|
||||
|
||||
if (is_string($name) && preg_match('/^\d{4}_ratemas$/', $name) === 1) {
|
||||
$tables[$name] = 'RateMas '.substr($name, 0, 4);
|
||||
}
|
||||
}
|
||||
|
||||
ksort($tables);
|
||||
|
||||
return $tables;
|
||||
}
|
||||
}
|
||||
17
app/Http/Middleware/EnsureAdmin.php
Normal file
17
app/Http/Middleware/EnsureAdmin.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
55
app/Models/User.php
Normal file
55
app/Models/User.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'username',
|
||||
'role',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
259
app/Services/RateMasImportService.php
Normal file
259
app/Services/RateMasImportService.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class RateMasImportService
|
||||
{
|
||||
private const COLUMNS = [
|
||||
'no_akaun' => 'string',
|
||||
'no_akaun_lama' => 'string',
|
||||
'nama_pemilik' => 'string',
|
||||
'alamatpos_1' => 'string',
|
||||
'alamatpos_2' => 'string',
|
||||
'alamatpos_3' => 'string',
|
||||
'ic_no' => 'string',
|
||||
'old_ic_no' => 'string',
|
||||
'bangsa' => 'string',
|
||||
'warganegara' => 'string',
|
||||
'nolot' => 'string',
|
||||
'nogeran' => 'string',
|
||||
'kegunaan' => 'string',
|
||||
'mukim' => 'string',
|
||||
'no_bangunan' => 'string',
|
||||
'lokasiharta_1' => 'string',
|
||||
'lokasiharta_2' => 'string',
|
||||
'kawasan' => 'string',
|
||||
'jenisbangunan' => 'string',
|
||||
'new_taksiran' => 'decimal',
|
||||
'kadar' => 'decimal',
|
||||
'cukai_setengahtahun' => 'decimal',
|
||||
'tarikh_kuatkuasa' => 'string',
|
||||
'old_taksiran' => 'decimal',
|
||||
'old_kadar' => 'decimal',
|
||||
'tunggakan_cukai' => 'decimal',
|
||||
'cukai_semasa_1' => 'decimal',
|
||||
'cukai_semasa_2' => 'decimal',
|
||||
'pelarasan_cukai' => 'decimal',
|
||||
'bayaran_cukai_diterima' => 'decimal',
|
||||
'baki_cukai' => 'decimal',
|
||||
'baki_najis' => 'decimal',
|
||||
'baki_notis' => 'decimal',
|
||||
'baki_waran' => 'decimal',
|
||||
'jumlah_baki' => 'decimal',
|
||||
];
|
||||
|
||||
public function import(int $year, string $path, string $extension, bool $replaceExisting): array
|
||||
{
|
||||
$table = $year.'_ratemas';
|
||||
|
||||
if (! preg_match('/^\d{4}_ratemas$/', $table)) {
|
||||
$this->fail('Tahun tidak sah.');
|
||||
}
|
||||
|
||||
$tableExists = Schema::hasTable($table);
|
||||
if ($tableExists && ! $replaceExisting) {
|
||||
$this->fail("Table {$table} sudah wujud. Tandakan pilihan ganti data jika mahu overwrite.");
|
||||
}
|
||||
|
||||
$rows = $extension === 'xml'
|
||||
? $this->readXml($path)
|
||||
: $this->readCsv($path);
|
||||
|
||||
if ($rows === []) {
|
||||
$this->fail('Fail tidak mengandungi rekod yang boleh diimport.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($table, $tableExists, $replaceExisting, $rows): void {
|
||||
if (! $tableExists) {
|
||||
$this->createRateMasTable($table);
|
||||
} elseif ($replaceExisting) {
|
||||
DB::table($table)->truncate();
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
DB::table($table)->insert($chunk);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'table' => $table,
|
||||
'rows' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
private function createRateMasTable(string $table): void
|
||||
{
|
||||
Schema::create($table, function (Blueprint $blueprint): void {
|
||||
$blueprint->string('no_akaun')->primary();
|
||||
|
||||
foreach (self::COLUMNS as $column => $type) {
|
||||
if ($column === 'no_akaun') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'decimal') {
|
||||
$blueprint->decimal($column, 14, 2)->default(0);
|
||||
} else {
|
||||
$blueprint->text($column)->nullable();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function readCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
$this->fail('Fail CSV tidak boleh dibaca.');
|
||||
}
|
||||
|
||||
$firstLine = fgets($handle) ?: '';
|
||||
rewind($handle);
|
||||
|
||||
$delimiter = $this->detectDelimiter($firstLine);
|
||||
$headers = fgetcsv($handle, 0, $delimiter);
|
||||
if (! is_array($headers)) {
|
||||
fclose($handle);
|
||||
$this->fail('Header CSV tidak sah.');
|
||||
}
|
||||
|
||||
$headers = array_map(fn ($header) => $this->normalizeHeader((string) $header), $headers);
|
||||
$this->assertRequiredHeaders($headers);
|
||||
|
||||
$rows = [];
|
||||
while (($data = fgetcsv($handle, 0, $delimiter)) !== false) {
|
||||
if ($data === [null] || $this->isEmptyRow($data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $this->normalizeRow(array_combine($headers, array_pad($data, count($headers), null)) ?: []);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function readXml(string $path): array
|
||||
{
|
||||
$xml = simplexml_load_file($path);
|
||||
if (! $xml instanceof SimpleXMLElement) {
|
||||
$this->fail('Fail XML tidak sah.');
|
||||
}
|
||||
|
||||
$nodes = $this->recordNodes($xml);
|
||||
$rows = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$row = [];
|
||||
foreach ($node->children() as $key => $value) {
|
||||
$row[$this->normalizeHeader($key)] = trim((string) $value);
|
||||
}
|
||||
|
||||
if ($row !== []) {
|
||||
$rows[] = $this->normalizeRow($row);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->assertRequiredHeaders(array_keys($rows[0]));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SimpleXMLElement>
|
||||
*/
|
||||
private function recordNodes(SimpleXMLElement $xml): array
|
||||
{
|
||||
$children = iterator_to_array($xml->children());
|
||||
if ($children === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$first = reset($children);
|
||||
if ($first instanceof SimpleXMLElement && $first->children()->count() > 0) {
|
||||
return array_values(array_filter($children, fn ($child) => $child instanceof SimpleXMLElement));
|
||||
}
|
||||
|
||||
return [$xml];
|
||||
}
|
||||
|
||||
private function normalizeRow(array $row): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach (self::COLUMNS as $column => $type) {
|
||||
$value = $row[$column] ?? null;
|
||||
$normalized[$column] = $type === 'decimal'
|
||||
? $this->decimalValue($value)
|
||||
: $this->stringValue($value);
|
||||
}
|
||||
|
||||
if ($normalized['no_akaun'] === null || $normalized['no_akaun'] === '') {
|
||||
$this->fail('Setiap rekod mesti mempunyai no_akaun.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function detectDelimiter(string $line): string
|
||||
{
|
||||
$delimiters = [',', ';', "\t", '|'];
|
||||
$counts = array_map(fn ($delimiter) => substr_count($line, $delimiter), $delimiters);
|
||||
arsort($counts);
|
||||
|
||||
return $delimiters[(int) array_key_first($counts)];
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $header): string
|
||||
{
|
||||
return Str::of($header)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace([' ', '-', '.', '/', '(', ')', '%'], '_')
|
||||
->replaceMatches('/_+/', '_')
|
||||
->trim('_')
|
||||
->toString();
|
||||
}
|
||||
|
||||
private function assertRequiredHeaders(array $headers): void
|
||||
{
|
||||
if (! in_array('no_akaun', $headers, true)) {
|
||||
$this->fail('Fail mesti mempunyai kolum no_akaun.');
|
||||
}
|
||||
}
|
||||
|
||||
private function isEmptyRow(array $row): bool
|
||||
{
|
||||
return trim(implode('', array_map(fn ($value) => (string) $value, $row))) === '';
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function decimalValue(mixed $value): float
|
||||
{
|
||||
$value = trim((string) ($value ?? ''));
|
||||
$value = str_replace([',', 'RM', 'rm', ' '], '', $value);
|
||||
|
||||
return $value === '' || ! is_numeric($value) ? 0.0 : (float) $value;
|
||||
}
|
||||
|
||||
private function fail(string $message): never
|
||||
{
|
||||
throw new HttpResponseException(back()->withErrors(['file' => $message])->withInput());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user