DONE: add flexible json update saham, yuran, and pelaburan ui

This commit is contained in:
ISMAIL MASSERAN
2026-07-19 11:54:13 +08:00
parent fa2eaef6c4
commit cd1dc4b9d8
18 changed files with 4010 additions and 15 deletions
@@ -12,7 +12,7 @@ class UpdateVoterPelaburanFromJson extends Command
{--election_id= : Override election_id (default: latest in voter table)}
{--dry-run : Show what would change without updating}';
protected $description = 'Update voter pelaburan (dividen) from list-pelabur.json for latest election only';
protected $description = 'Update voter dividen_pelaburan (from JSON pelaburan) and accumulated_amount from list-pelabur.json for latest election only';
public function handle()
{
@@ -51,8 +51,9 @@ class UpdateVoterPelaburanFromJson extends Command
continue;
}
// JSON key "pelaburan" is the dividend amount → voter.dividen_pelaburan
$map[$noAnggota] = [
'pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
'dividen_pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
'accumulated_amount' => $this->cleanNumber($row['accumulated_amount'] ?? 0),
];
}
@@ -67,6 +68,7 @@ class UpdateVoterPelaburanFromJson extends Command
$this->info("Target election_id: {$electionId}");
$this->info("JSON members loaded: " . count($map));
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
$this->info("Mapping: JSON.pelaburan → voter.dividen_pelaburan, JSON.accumulated_amount → voter.accumulated_amount");
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
$this->info("Voters in election: {$totalInElection}");
@@ -80,7 +82,7 @@ class UpdateVoterPelaburanFromJson extends Command
$bar->start();
DB::table('voter')
->select(['id', 'no_anggota', 'pelaburan', 'accumulated_amount'])
->select(['id', 'no_anggota', 'dividen_pelaburan', 'accumulated_amount'])
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
@@ -96,11 +98,11 @@ class UpdateVoterPelaburanFromJson extends Command
$matched++;
$jsonRow = $map[$noAnggota];
$oldPelaburan = $voter->pelaburan !== null ? (float) $voter->pelaburan : 0.0;
$oldDividen = $voter->dividen_pelaburan !== null ? (float) $voter->dividen_pelaburan : 0.0;
$oldAccumulated = $voter->accumulated_amount !== null ? (float) $voter->accumulated_amount : 0.0;
if (
abs($oldPelaburan - $jsonRow['pelaburan']) < 0.00001
abs($oldDividen - $jsonRow['dividen_pelaburan']) < 0.00001
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
) {
$unchanged++;
@@ -112,7 +114,7 @@ class UpdateVoterPelaburanFromJson extends Command
DB::table('voter')
->where('id', $voter->id)
->update([
'pelaburan' => $jsonRow['pelaburan'],
'dividen_pelaburan' => $jsonRow['dividen_pelaburan'],
'accumulated_amount' => $jsonRow['accumulated_amount'],
'updated_at' => now(),
]);
@@ -145,7 +147,7 @@ class UpdateVoterPelaburanFromJson extends Command
private function cleanNumber($value): float
{
if ($value === null) {
if ($value === null || $value === '') {
return 0.0;
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Import keanggotaan JSON (match by no_anggota) into voter money columns for the current election.
* Expected row keys: no_anggota, pelaburan, pelaburan_2, saham, yuran. Null/empty → 0.
*/
class ImportKeanggotaanJsonController extends Controller
{
private const UPDATABLE_FIELDS = [
'pelaburan',
'pelaburan_2',
'saham',
'yuran',
];
public function __invoke(Request $request)
{
$this->validate($request, [
'file' => 'required|file|max:10240',
]);
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension() ?: '');
if ($extension !== 'json') {
return response()->json([
'status' => 'failed',
'message' => 'Fail mestilah format .json.',
], 422);
}
$raw = file_get_contents($file->getRealPath());
$rows = json_decode($raw, true);
if (!is_array($rows)) {
return response()->json([
'status' => 'failed',
'message' => 'JSON tidak sah. Jangkaan array objek.',
], 422);
}
$map = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
if ($noAnggota === '') {
continue;
}
$payload = [];
foreach (self::UPDATABLE_FIELDS as $field) {
if (array_key_exists($field, $row)) {
$payload[$field] = $this->cleanNumber($row[$field]);
}
}
if (count($payload) === 0) {
continue;
}
$map[$noAnggota] = $payload;
}
if (count($map) === 0) {
return response()->json([
'status' => 'failed',
'message' => 'Tiada baris sah dalam JSON (perlu no_anggota dan sekurang-kurangnya satu medan).',
], 422);
}
$electionId = (int) Util::getCurrentElection();
if ($electionId <= 0) {
return response()->json([
'status' => 'failed',
'message' => 'Tidak dapat mengenal pasti election semasa.',
], 422);
}
$stats = [
'election_id' => $electionId,
'json_rows' => count($map),
'matched' => 0,
'updated' => 0,
'unchanged' => 0,
'missing_in_voter' => 0,
'missing_in_json' => 0,
];
$matchedKeys = [];
DB::table('voter')
->select(array_merge(['id', 'no_anggota'], self::UPDATABLE_FIELDS))
->where('election_id', $electionId)
->orderBy('id')
->chunkById(500, function ($chunk) use ($map, &$stats, &$matchedKeys) {
foreach ($chunk as $voter) {
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
$stats['missing_in_json']++;
continue;
}
$stats['matched']++;
$matchedKeys[$noAnggota] = true;
$jsonRow = $map[$noAnggota];
$update = [];
foreach ($jsonRow as $field => $newValue) {
$oldValue = $voter->{$field} !== null ? (float) $voter->{$field} : 0.0;
if (abs($oldValue - $newValue) >= 0.00001) {
$update[$field] = $newValue;
}
}
if (count($update) === 0) {
$stats['unchanged']++;
continue;
}
$update['updated_at'] = now();
DB::table('voter')->where('id', $voter->id)->update($update);
$stats['updated']++;
}
}, 'id');
foreach ($map as $noAnggota => $_payload) {
if (!isset($matchedKeys[$noAnggota])) {
$stats['missing_in_voter']++;
}
}
activity()
->withProperties([
'election_id' => $electionId,
'filename' => $file->getClientOriginalName(),
'stats' => $stats,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('import keanggotaan json');
return response()->json([
'status' => 'success',
'message' => 'Import keanggotaan berjaya.',
'data' => $stats,
]);
}
private function cleanNumber($value): float
{
if ($value === null || $value === '') {
return 0.0;
}
$value = (string) $value;
$value = str_replace(['RM', ',', ' '], '', $value);
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
$value = $matches[1];
}
$value = preg_replace('/[^0-9\.\-]/', '', $value);
return is_numeric($value) ? (float) $value : 0.0;
}
}
@@ -41,6 +41,8 @@ class ElectionMiddleware
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
&& !($request->is('api/v1/voter/sync-applicable') && $request->isMethod('post'))
// Allow keanggotaan JSON import during election
&& !($request->is('api/v1/voter/import-keanggotaan-json') && $request->isMethod('post'))
&& !$request->is('api/v1/voter/*/verify-fizikal-registration')
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
&& !$request->is('api/v1/admin')
+1 -1
View File
@@ -11,7 +11,7 @@ class Voter extends Authenticatable
protected $table = 'voter';
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran', 'saham_terkini', 'yuran_terkini', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan', 'accumulated_amount', 'tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran', 'saham_terkini', 'yuran_terkini', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan', 'dividen_pelaburan', 'pelaburan_terkini', 'pelaburan_2', 'dividen_pelaburan_2', 'pelaburan_terkini_2', 'accumulated_amount', 'tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $casts = [
'fizikal_registration_verified_at' => 'datetime',