feat(shoraka): defer gadai persistence until commodity confirmation and add simulation support

This commit is contained in:
2026-10-19 13:07:10 +08:00
parent 63d8c23785
commit 011fbfe8b2
18 changed files with 437 additions and 59 deletions
+62 -32
View File
@@ -184,13 +184,16 @@ class GadaianController extends Controller
'norujukan' => $norujukan,
'nopelanggan' => $customer_info['nopelanggan'],
'nokp' => $noic,
'sirig' => $new_sirig
]);
'sirig' => $new_sirig,
'shoraka_new' => true,
])->throw()->json();
$norujukan = $gadai['norujukan'];
Session::put('norujukan', $norujukan);
} else {
$norujukan = Session::get('norujukan');
$nokp = Session::get('nokp');
$nopelanggan = Session::get('nopelanggan');
$gadai_session = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/check/sag', ['kodcaw' => $auth_user['cawangan'], 'norujukan' => $norujukan, 'nokp' => $nokp])->json();
$gadai_session = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/check/sag', ['kodcaw' => $auth_user['cawangan'], 'norujukan' => $norujukan, 'nokp' => $nokp])->throw()->json();
if ($gadai_session == null) {
$gadai_transaction = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/gadai/' . $auth_user['cawangan'] . '/' . $current->toDateString())->json();
if ($gadai_transaction) {
@@ -209,9 +212,11 @@ class GadaianController extends Controller
'norujukan' => $norujukan_baru,
'nopelanggan' => $nopelanggan,
'nokp' => $nokp,
'sirig' => $new_sirig
]);
$norujukan = $norujukan_baru;
'sirig' => $new_sirig,
'shoraka_new' => true,
])->throw()->json();
$norujukan = $gadai_baru['norujukan'];
Session::put('norujukan', $norujukan);
}
}
@@ -353,6 +358,7 @@ class GadaianController extends Controller
return $shoraka->hold($request, $request->norujukan_gadai, 'gadai', [
'cara_terima' => $caraTerima,
'pinjaman' => $pinjamanAmt,
'temporary_gadai' => true,
]);
}
@@ -544,31 +550,55 @@ class GadaianController extends Controller
$keuntungan6bulan = $keuntungan_sebln * 6;
$keuntungan12bulan = $keuntungan_sebln * 12;
$hargajualan = $request->pinjaman + $keuntungan12bulan;
$gadai = Http::withOptions(['verify' => config('app.api_verify')])->put(config('api.url') . '/api/gadai/norujukan/' . $auth_user['cawangan'] . '/' . $request->norujukan_gadai, [
'kodcaw' => $auth_user['cawangan'],
'nilaimarhun' => round($total_nilai_marhun, 2),
'berat' => round($request->total_berat, 2),
'pinjaman' => round($request->pinjaman, 2),
'kadarupah' => round($kadarupah, 2),
'marhun' => $request->marhun_detail,
'percentpinj' => $margin_pinjaman,
'teller' => $auth_user['name'],
'upah6' => $keuntungan6bulan,
'upah12' => $keuntungan12bulan,
'hargajualan' => $hargajualan,
'kuantiti_marhun' => $request->kuantiti_marhun,
'transaction' => 'Ubah Gadai',
'kadarujrah' => round($kadarupah, 2),
'ujrah' => $ujrah,
'onepercent' => $onepercent,
'margin_semasa' => $margin_pinjaman,
'kadar_rebate' => $this->parseMoney($request->kadar_rebate),
'rebate' => $this->parseMoney($request->rebate),
])->json();
$update_komuditi = Http::withOptions(['verify' => config('app.api_verify')])->put(config('api.url') . '/api/komuditi/update', [
'unit_komuditi' => round($request->pinjaman, 2),
'norujukan' => $request->norujukan_gadai
])->json();
try {
$gadai = Http::withOptions(['verify' => config('app.api_verify')])->put(config('api.url') . '/api/gadai/norujukan/' . $auth_user['cawangan'] . '/' . $request->norujukan_gadai, [
'kodcaw' => $auth_user['cawangan'],
'nilaimarhun' => round($total_nilai_marhun, 2),
'berat' => round($request->total_berat, 2),
'pinjaman' => round($request->pinjaman, 2),
'kadarupah' => round($kadarupah, 2),
'marhun' => $request->marhun_detail,
'percentpinj' => $margin_pinjaman,
'teller' => $auth_user['name'],
'upah6' => $keuntungan6bulan,
'upah12' => $keuntungan12bulan,
'hargajualan' => $hargajualan,
'kuantiti_marhun' => $request->kuantiti_marhun,
'transaction' => 'Ubah Gadai',
'kadarujrah' => round($kadarupah, 2),
'ujrah' => $ujrah,
'onepercent' => $onepercent,
'margin_semasa' => $margin_pinjaman,
'kadar_rebate' => $this->parseMoney($request->kadar_rebate),
'rebate' => $this->parseMoney($request->rebate),
]);
} catch (\Illuminate\Http\Client\ConnectionException $exception) {
return back()->withInput()->with('gadai_edit_error',
'Sambungan terganggu. Semak rekod gadai sebelum mencuba semula.');
}
if (!$gadai->successful()) {
$message = 'Ubah Gadai tidak berjaya. Sila semak dengan operasi.';
if ($gadai->status() === 409 || $gadai->status() === 422) {
$apiMessage = $gadai->json()['message'] ?? null;
if (is_string($apiMessage) && $apiMessage !== '') {
$message = $apiMessage;
}
}
return back()->withInput()->with('gadai_edit_error', $message);
}
try {
$update_komuditi = Http::withOptions(['verify' => config('app.api_verify')])->put(config('api.url') . '/api/komuditi/update', [
'unit_komuditi' => round($request->pinjaman, 2),
'norujukan' => $request->norujukan_gadai
]);
} catch (\Illuminate\Http\Client\ConnectionException $exception) {
return back()->with('gadai_edit_error',
'Rekod gadai telah dikemas kini tetapi kemas kini komoditi tidak dapat disahkan. Sila semak dengan operasi.');
}
if (!$update_komuditi->successful()) {
return back()->with('gadai_edit_error',
'Rekod gadai telah dikemas kini tetapi kemas kini komoditi gagal. Sila semak dengan operasi.');
}
event(new GadaiNotify('lulus', $auth_user['cawangan']));
@@ -582,7 +612,7 @@ class GadaianController extends Controller
$norujukan = $request->norujukan;
$auth_user = Auth::user();
Http::withOptions(['verify' => config('app.api_verify')])->delete(config('api.url') . '/api/gadai/batal/' . $auth_user['cawangan'] . '/' . $norujukan);
Http::withOptions(['verify' => config('app.api_verify')])->delete(config('api.url') . '/api/gadai/batal/' . $auth_user['cawangan'] . '/' . $norujukan)->throw();
$request->session()->forget('page_reload');
$request->session()->forget('norujukan');
$request->session()->forget('borang_online_printed');
@@ -18,6 +18,49 @@ class ShorakaController extends Controller
return view('shoraka.index', compact('records', 'cawangan_info'));
}
public function operasiIndex(Request $request)
{
abort_unless(in_array(Auth::user()->roles, ['operasi', 'pc', 'ppc'], true), 403);
$isOperasi = Auth::user()->roles === 'operasi';
$filters = $request->validate([
'kodcaw' => 'nullable|string|max:30',
'norujukan' => 'nullable|string|max:100',
'state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE',
'date_from' => 'nullable|date_format:Y-m-d',
'date_to' => 'nullable|date_format:Y-m-d' . ($request->filled('date_from') ? '|after_or_equal:date_from' : ''),
]);
$branchQuery = ShorakaFinancing::select('kodcaw');
if (!$isOperasi) {
$branchQuery->where('kodcaw', Auth::user()->cawangan);
}
$branches = $branchQuery->distinct()->orderBy('kodcaw')->pluck('kodcaw');
$query = ShorakaFinancing::select('id', 'kodcaw', 'norujukan', 'teller', 'action', 'state', 'created_at', 'updated_at');
if (!$isOperasi) {
$query->where('kodcaw', Auth::user()->cawangan);
}
foreach (['kodcaw', 'norujukan', 'state'] as $field) {
if (!empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (!empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from'] . ' 00:00:00');
}
if (!empty($filters['date_to'])) {
$query->where('created_at', '<', \Carbon\Carbon::parse($filters['date_to'])->addDay()->format('Y-m-d') . ' 00:00:00');
}
$records = $query->orderByDesc('created_at')->orderByDesc('id')
->paginate(25)->appends($filters);
$cawangan_info = ['details_caw' => Auth::user()->cawangan, 'kodcaw' => Auth::user()->cawangan];
return view('shoraka.operasi', compact('records', 'branches', 'filters', 'isOperasi', 'cawangan_info'));
}
public function pcIndex(Request $request)
{
abort_unless(in_array(Auth::user()->roles, ['pc', 'ppc'], true), 403);
return $this->operasiIndex($request);
}
public function show(string $norujukan, ShorakaFinancingService $service)
{
$record = $service->local($norujukan);
@@ -32,6 +75,62 @@ class ShorakaController extends Controller
return view('shoraka.show', compact('norujukan', 'record', 'data', 'error', 'cawangan_info'));
}
public function poll(Request $request, string $norujukan, ShorakaFinancingService $service)
{
$record = $service->local($norujukan);
abort_unless($record && $record->teller === Auth::user()->name && $record->action === 'gadai', 403);
if ($record->state === 'DONE') {
return response()->json(['state' => 'done', 'redirect' => route('customer_info')]);
}
abort_unless(!empty($record->context['temporary_gadai']), 409);
if ($record->state === 'REVIEW') {
return response()->json(['state' => 'failed', 'message' => 'Penyelesaian memerlukan semakan operasi. Jangan ulang bayaran.']);
}
if ($record->state === 'PROCESSING') {
return response()->json(['state' => 'pending']);
}
try {
$data = $service->api()->get($service->url($norujukan))->throw()->json()['data'];
$order = $data['order'] ?? null;
if ($order && !empty($order['order_id']) && ($order['status'] ?? '') !== 'Cancelled') {
$service->api()->post($service->url($norujukan, '/refresh'))->throw();
$data = $service->api()->get($service->url($norujukan))->throw()->json()['data'];
$order = $data['order'] ?? null;
}
$status = $order['status'] ?? null;
if (in_array($status, ['Cancelled', 'FAILED', 'UNKNOWN'], true) || !$order) {
return response()->json(['state' => 'failed', 'message' => !empty($data['simulation'])
? 'Simulasi gagal berjaya diuji. Tiada gadaian atau marhun disimpan dan tiada bayaran diproses.'
: 'Transaksi gagal atau hasil penghantaran belum dapat dipastikan. Hubungi operasi; jangan hantar semula.']);
}
if (!empty($data['can_complete'])) {
if ($request->input('progress') === 'check') {
return response()->json(['state' => 'ready']);
}
$result = $service->finish($request, $norujukan);
return response()->json(['state' => 'done', 'redirect' => $result->getTargetUrl()]);
}
if ($status === 'Completed') {
return response()->json(['state' => 'failed', 'message' => 'Komoditi selesai tetapi maklumat gadaian belum dapat disahkan. Hubungi operasi.']);
}
return response()->json(['state' => 'pending']);
} catch (Throwable $exception) {
if ($record->fresh()->state === 'REVIEW') {
return response()->json(['state' => 'failed', 'message' => 'Penyelesaian memerlukan semakan operasi. Jangan ulang bayaran.']);
}
if ($exception instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface
&& $exception->getStatusCode() >= 400 && $exception->getStatusCode() < 500) {
return response()->json(['state' => 'failed', 'message' => $exception->getMessage()]);
}
if ($exception instanceof \Illuminate\Http\Client\RequestException
&& in_array($exception->response->status(), [404, 409], true)) {
return response()->json(['state' => 'failed', 'message' => 'Maklumat transaksi tidak dapat disahkan. Hubungi operasi; jangan hantar semula.']);
}
// Connection loss is not proof of a failed commodity trade.
return response()->json(['state' => 'pending']);
}
}
public function submit(string $norujukan, ShorakaFinancingService $service)
{
$record = $service->local($norujukan);
+23 -3
View File
@@ -10,12 +10,13 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Throwable;
use Illuminate\Support\Facades\Cache;
class ShorakaFinancingService
{
public function ensureReady(): void
{
// Check local migration before any registration side effect.
// Check the configured financing database before any registration side effect.
ShorakaFinancing::where('kodcaw', Auth::user()->cawangan)->exists();
$response = $this->api()->get(rtrim(config('api.url'), '/') . '/api/shoraka/readiness')->throw()->json();
abort_unless(!empty($response['ready']), 503, 'Shoraka belum dikonfigurasi. Pembiayaan baharu ditahan.');
@@ -42,15 +43,25 @@ class ShorakaFinancingService
public function hold(Request $request, string $reference, string $action, array $extra = [])
{
$user = Auth::user();
$context = array_merge($request->except(['_token', 'password']), $extra);
if (!empty($extra['temporary_gadai'])) {
$key = 'shoraka:payout:' . hash('sha256', $user->cawangan . ':' . $reference);
$temporaryContext = $context;
$context = ['temporary_gadai' => true, 'pinjaman' => $extra['pinjaman']];
}
$record = ShorakaFinancing::firstOrCreate(
['kodcaw' => $user->cawangan, 'norujukan' => $reference],
[
'teller' => $user->name, 'action' => $action, 'state' => 'WAITING',
'context' => array_merge($request->except(['_token', 'password']), $extra),
'context' => $context,
]
);
// A duplicate request only opens the saved workflow; it never replays registration.
if ($record->wasRecentlyCreated) {
if (isset($temporaryContext) && !Cache::store('file')->put($key, $temporaryContext, 604800)) {
$record->update(['state' => 'REVIEW']);
abort(503, 'Maklumat pembiayaan sementara tidak dapat disimpan.');
}
try {
$this->api()->post($this->url($reference, '/submit'));
} catch (Throwable $exception) {
@@ -91,11 +102,17 @@ class ShorakaFinancingService
409, 'Amaun pembiayaan berubah. Semakan operasi diperlukan sebelum bayaran.'
);
$context = $record->context;
if (!empty($context['temporary_gadai'])) {
$context = Cache::store('file')->get('shoraka:payout:' . hash('sha256', $record->kodcaw . ':' . $reference));
abort_unless(is_array($context), 409, 'Maklumat sementara tamat. Semakan operasi diperlukan sebelum bayaran.');
}
$claimed = ShorakaFinancing::where('id', $record->id)->where('state', 'WAITING')
->update(['state' => 'PROCESSING', 'updated_at' => now()]);
abort_unless($claimed === 1, 409, 'Penyelesaian sedang diproses.');
$saved = new Request($record->context);
$saved = new Request($context);
$saved->setLaravelSession($request->session());
try {
if ($record->action === 'gadai') {
@@ -119,6 +136,9 @@ class ShorakaFinancingService
$result = redirect()->route('shoraka.show', ['norujukan' => $reference]);
}
$record->update(['state' => 'DONE', 'context' => []]);
if (!empty($context['temporary_gadai'])) {
Cache::store('file')->forget('shoraka:payout:' . hash('sha256', $record->kodcaw . ':' . $reference));
}
return $result;
} catch (Throwable $exception) {
// A failed remote payment response can be ambiguous. Never retry automatically.
+2 -2
View File
@@ -2,13 +2,13 @@
if (!function_exists('api_url_for_browser')) {
/**
* Base URL for JavaScript in the browser, derived from API_URL (config api.url).
* Base URL for JavaScript, using API_BROWSER_URL when configured.
* Docker dev often uses host.docker.internal for PHP→host; browsers may not resolve it.
* Same host-mapped port is reachable at 127.0.0.1. Production URLs are unchanged.
*/
function api_url_for_browser(): string
{
$url = rtrim((string) config('api.url'), '/');
$url = rtrim((string) (config('api.browser_url') ?: config('api.url')), '/');
return str_ireplace('host.docker.internal', '127.0.0.1', $url);
}
+1
View File
@@ -1,6 +1,7 @@
<?php
return [
'url' => env('API_URL'),
'browser_url' => env('API_BROWSER_URL'),
'url_koop' => env('API_URL_KOOP','https://apiujrah.erahn.com.my'),
'url_pkb' => env('API_URL_PKB','http://localhost:8000'),
];
+6 -5
View File
@@ -5,6 +5,9 @@
@section('content')
@include('modal.editmarhun')
<div class="container">
@if(session('gadai_edit_error'))
<div class="alert alert-danger" role="alert">{{ session('gadai_edit_error') }}</div>
@endif
<div class="row justify-content-center">
<div class="col-md-12 col-lg-12">
<h3 class="text-center text-uppercase">Pembiayaan</h3>
@@ -946,6 +949,7 @@
text: `Komen : ${data.komen}`
}).then(function(){
$(func).attr("onSubmit","return true;");
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
$(func).submit();
return true;
});
@@ -1058,6 +1062,7 @@
}).then(function(){
$(func).attr("onSubmit","return true;");
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
$(func).submit();
return true;
@@ -1092,11 +1097,7 @@
return false;
}
else if(pinjaman < 10000 && pinjaman <= Number(pembiayaan_max_teller)){
Swal.fire({
icon: 'success',
title: 'Berjaya!',
text: 'Pembiayaan Berjaya Disimpan'
});
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
return true;
}
}
+3 -3
View File
@@ -1162,9 +1162,9 @@
return false;
} else {
Swal.fire({
icon: 'success',
title: 'Berjaya!',
text: 'Pembiayaan Berjaya Disimpan'
icon: 'info',
title: 'Sedang diproses',
text: 'Sedang menghantar pembiayaan untuk pengesahan Shoraka.'
});
$(func).attr("onSubmit","return true;");
+6 -5
View File
@@ -5,6 +5,9 @@
@section('content')
@include('modal.editmarhun')
<div class="container">
@if(session('gadai_edit_error'))
<div class="alert alert-danger" role="alert">{{ session('gadai_edit_error') }}</div>
@endif
<div class="row justify-content-center">
<div class="col-md-12 col-lg-12">
<h3 class="text-center text-uppercase">Pembiayaan</h3>
@@ -1612,6 +1615,7 @@
text: `Komen : ${data.komen}`
}).then(function(){
$(func).attr("onSubmit","return true;");
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
$(func).submit();
return true;
});
@@ -1724,6 +1728,7 @@
}).then(function(){
$(func).attr("onSubmit","return true;");
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
$(func).submit();
return true;
@@ -1758,11 +1763,7 @@
return false;
}
else if(pinjaman < 10000 && pinjaman <= Number(pembiayaan_max_teller)){
Swal.fire({
icon: 'success',
title: 'Berjaya!',
text: 'Pembiayaan Berjaya Disimpan'
});
window.shorakaProgress(1, 'Sedang menghantar pembiayaan kepada Shoraka...');
return true;
}
}
+3 -3
View File
@@ -2000,9 +2000,9 @@
} else {
//insert code asal
Swal.fire({
icon: 'success',
title: 'Berjaya!',
text: 'Pembiayaan Berjaya Disimpan'
icon: 'info',
title: 'Sedang diproses',
text: 'Sedang menghantar pembiayaan untuk pengesahan Shoraka.'
});
$(func).attr("onSubmit","return true;");
@@ -268,6 +268,11 @@
<a href="{{ route('khazanah.harga_emas') }}"
class="btn btn-info btn-lg btn-block mb-3">Harga Emas</a>
</div>
@if(in_array(Auth::user()->roles, ['pc', 'ppc'], true))
<div class="col-4 px-2">
<a href="{{ route('pc.shoraka.index') }}" class="btn btn-info btn-lg btn-block mb-3">Komoditi Shoraka</a>
</div>
@endif
@if(!empty(($cawangan_info['features'] ?? [])['pembiayaan_online']))
<div class="col-4 px-2">
<a href="{{ route('pembiayaan.online.pc') }}"
@@ -16,6 +16,7 @@
<div class="card-body text-center">
<a href="{{ route('operasi.kelulusananggota') }}" class="btn btn-info btn-lg d-inline-block m-1">Kelulusan Anggota</a>
<a href="{{ route('operasi.kelulusan') }}" class="btn btn-info btn-lg d-inline-block m-1">Kelulusan</a>
<a href="{{ route('operasi.shoraka.index') }}" class="btn btn-info btn-lg d-inline-block m-1">Komoditi Shoraka</a>
<a href="{{ route('pembiayaan.online.operasi') }}" class="btn btn-info btn-lg d-inline-block m-1">Pembiayaan Online</a>
<a href="{{ route('pembiayaan.online.operasi.topup') }}" class="btn btn-info btn-lg d-inline-block m-1">Modal Online</a>
<a href="{{ route('pembiayaan.online.operasi.eod') }}" class="btn btn-info btn-lg d-inline-block m-1">EOD Modal Online</a>
@@ -60,7 +60,7 @@
<a class="btn btn-info btn-lg" href="{{ url('/harga_emas') }}">HARGA EMAS</a>
<a class="btn btn-info btn-lg" href="{{ url('/aliran_tunai') }}">ALIRAN TUNAI</a>
@if(in_array(Auth::user()->roles, ['teller', 'penilai', 'pc', 'ppc', 'admin', 'masteradmin', 'operasi']) || Auth::user()->role_harian === 'teller')
<a class="btn btn-info btn-lg" href="{{ route('shoraka.index') }}">PEMBIAYAAN SHORAKA</a>
<a class="btn btn-info btn-lg" href="{{ route('shoraka.index') }}">KOMODITI SHORAKA</a>
@endif
{{-- <a class="btn btn-info btn-lg" href="#">TETAPAN</a> --}}
</div>
+2 -2
View File
@@ -615,7 +615,7 @@
<td>{{ $komoditi['no_sijil'] }}</td>
<td class="text-center">4</td>
<td>Kuantiti (Unit)</td>
<td>{{ number_format($kuantiti, 2) }} TAN</td>
<td>{{ $kuantiti }} TONNES</td>
</tr>
<tr>
<td class="text-center">2</td>
@@ -1849,7 +1849,7 @@
<td>{{ $komoditi['no_sijil'] }}</td>
<td class="text-center">4</td>
<td>Kuantiti</td>
<td>{{ number_format($kuantiti, 2) }}</td>
<td>{{ $kuantiti }}</td>
</tr>
<tr>
<td class="text-center">2</td>
+2 -2
View File
@@ -20,13 +20,13 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb pb-3">
<li class="breadcrumb-item"><a href="{{ route('home') }}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Pembiayaan Shoraka</li>
<li class="breadcrumb-item active" aria-current="page">Komoditi Shoraka</li>
</ol>
</nav>
<div class="card shoraka-list-card">
<div class="card-header bg-secondary d-flex flex-wrap align-items-center justify-content-between">
<h5 class="mb-0 py-2">Pembiayaan Shoraka</h5>
<h5 class="mb-0 py-2">Komoditi Shoraka</h5>
<a class="btn btn-info btn-sm my-1" href="{{ route('home') }}">
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Kembali ke Paparan Utama
</a>
@@ -1,3 +1,50 @@
<div id="shoraka-modal" hidden style="position:fixed;inset:0;z-index:20000;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;padding:16px">
<div role="dialog" aria-modal="true" aria-labelledby="shoraka-modal-title" tabindex="-1" style="background:#fff;border-radius:5px;padding:24px;max-width:460px;width:100%;text-align:center;box-shadow:0 8px 30px #0003">
<h4 id="shoraka-modal-title">Pembelian Komoditi Shoraka</h4>
<p id="shoraka-modal-message" role="status" aria-live="polite">Sedang memproses pembelian komoditi...</p>
<ol style="text-align:left;padding-left:28px">
<li data-shoraka-step="1">Menghantar order ke Shoraka</li>
<li data-shoraka-step="2">Menunggu pengesahan Shoraka</li>
<li data-shoraka-step="3">Menyelesaikan pembiayaan dan menyimpan gadaian</li>
</ol>
<p class="text-muted small">Sila tunggu sehingga proses selesai. Jangan hantar transaksi semula.</p>
<div id="shoraka-modal-spinner" class="spinner-border text-primary" role="status"><span class="sr-only">Sedang memproses</span></div>
<button id="shoraka-modal-close" type="button" class="btn btn-secondary" hidden>Tutup</button>
</div>
</div>
<script>
window.shorakaProgress = (function () {
var modal = document.getElementById('shoraka-modal');
var close = document.getElementById('shoraka-modal-close');
var previousFocus;
close.addEventListener('click', function () {
modal.hidden = true;
modal.style.display = 'none';
if (previousFocus) { previousFocus.focus(); }
});
modal.addEventListener('keydown', function (event) {
if (event.key === 'Tab') {
event.preventDefault();
(close.hidden ? modal.querySelector('[role="dialog"]') : close).focus();
}
});
return function (step, message, stopped) {
if (modal.hidden) { previousFocus = document.activeElement; }
modal.hidden = false;
modal.style.display = 'flex';
document.getElementById('shoraka-modal-message').textContent = message || 'Sedang memproses pembelian komoditi...';
modal.querySelectorAll('[data-shoraka-step]').forEach(function (item) {
var number = Number(item.getAttribute('data-shoraka-step'));
item.className = number < step ? 'text-success' : (number === step ? 'text-primary font-weight-bold' : 'text-muted');
if (number === step) { item.setAttribute('aria-current', 'step'); }
else { item.removeAttribute('aria-current'); }
});
document.getElementById('shoraka-modal-spinner').style.display = stopped ? 'none' : 'inline-block';
close.hidden = !stopped;
(stopped ? close : modal.querySelector('[role="dialog"]')).focus();
};
}());
</script>
<script>
// Existing refinancing AJAX handlers must not print receipts or announce payment
// success when the server has deferred the financial completion.
+97
View File
@@ -0,0 +1,97 @@
@extends($isOperasi ? 'layouts.app_operasi' : 'layouts.app')
@section('content')
@php
$listRoute = $isOperasi ? 'operasi.shoraka.index' : 'pc.shoraka.index';
$statusLabels = ['WAITING' => 'Menunggu Shoraka / penyelesaian', 'PROCESSING' => 'Sedang diproses', 'REVIEW' => 'Perlu semakan operasi', 'DONE' => 'Selesai'];
$statusColors = ['WAITING' => 'warning', 'PROCESSING' => 'info', 'REVIEW' => 'danger', 'DONE' => 'success'];
@endphp
<div class="container-fluid">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('home') }}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Komoditi Shoraka — {{ $isOperasi ? 'Operasi' : strtoupper(Auth::user()->roles) }}</li>
</ol>
</nav>
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center">
<h5 class="mb-0 py-2">Komoditi Shoraka — {{ $isOperasi ? 'Semua Cawangan' : Auth::user()->cawangan }}</h5>
<a href="{{ route('home') }}" class="btn btn-info btn-sm">Kembali ke Paparan Utama</a>
</div>
<div class="card-body">
<p class="text-muted">Paparan pemantauan rekod pembiayaan {{ $isOperasi ? 'semua cawangan' : 'cawangan sendiri' }}. Status di bawah ialah status penyelesaian dalam sistem kaunter, bukan status langsung daripada Shoraka.</p>
@if($errors->any())
<div class="alert alert-danger" role="alert">{{ $errors->first() }}</div>
@endif
<form method="get" action="{{ route($listRoute) }}" class="mb-4">
<div class="form-row align-items-end">
@if($isOperasi)
<div class="form-group col-md-3">
<label for="shoraka-branch">Cawangan</label>
<select id="shoraka-branch" name="kodcaw" class="form-control">
<option value="">{{ $isOperasi ? 'Semua cawangan' : Auth::user()->cawangan }}</option>
@foreach($branches as $branch)
<option value="{{ $branch }}" {{ ($filters['kodcaw'] ?? '') === $branch ? 'selected' : '' }}>{{ $branch }}</option>
@endforeach
</select>
</div>
@endif
<div class="form-group col-md-3">
<label for="shoraka-reference">No. rujukan penuh</label>
<input id="shoraka-reference" name="norujukan" class="form-control" maxlength="100" value="{{ $filters['norujukan'] ?? '' }}">
</div>
<div class="form-group col-md-3">
<label for="shoraka-state">Penyelesaian pembiayaan</label>
<select id="shoraka-state" name="state" class="form-control">
<option value="">Semua status</option>
@foreach($statusLabels as $state => $label)
<option value="{{ $state }}" {{ ($filters['state'] ?? '') === $state ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-3">
<label for="shoraka-date-from">Tarikh mula didaftarkan</label>
<input type="date" id="shoraka-date-from" name="date_from" class="form-control" value="{{ $filters['date_from'] ?? '' }}">
</div>
<div class="form-group col-md-3">
<label for="shoraka-date-to">Tarikh akhir didaftarkan</label>
<input type="date" id="shoraka-date-to" name="date_to" class="form-control" value="{{ $filters['date_to'] ?? '' }}">
</div>
<div class="form-group col-md-3">
<button type="submit" class="btn btn-primary">Tapis</button>
<a href="{{ route($listRoute) }}" class="btn btn-secondary">Reset</a>
</div>
</div>
</form>
<p>{{ $records->total() }} rekod</p>
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover">
<thead class="thead-dark"><tr>
<th scope="col">Cawangan</th>
<th scope="col">No. rujukan</th>
<th scope="col">Teller</th>
<th scope="col">Penyelesaian pembiayaan</th>
<th scope="col">Didaftarkan</th>
<th scope="col">Dikemas kini</th>
</tr></thead>
<tbody>
@forelse($records as $record)
<tr>
<td>{{ $record->kodcaw }}</td>
<td class="text-nowrap">{{ $record->norujukan }}</td>
<td>{{ $record->teller }}</td>
<td><span class="badge badge-{{ $statusColors[$record->state] ?? 'secondary' }} p-2">{{ $statusLabels[$record->state] ?? $record->state }}</span></td>
<td class="text-nowrap">{{ $record->created_at ? $record->created_at->format('d/m/Y H:i') : '—' }}</td>
<td class="text-nowrap">{{ $record->updated_at ? $record->updated_at->format('d/m/Y H:i') : '—' }}</td>
</tr>
@empty
<tr><td colspan="6" class="text-center text-muted py-4">Tiada transaksi untuk tapisan ini.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $records->links() }}
</div>
</div>
</div>
@endsection
+74 -1
View File
@@ -7,7 +7,7 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb pb-3">
<li class="breadcrumb-item"><a href="{{ route('home') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ route('shoraka.index') }}">Pembiayaan Shoraka</a></li>
<li class="breadcrumb-item"><a href="{{ route('shoraka.index') }}">Komoditi Shoraka</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ $norujukan }}</li>
</ol>
</nav>
@@ -20,6 +20,78 @@
</a>
</div>
<div class="card-body">
@if(!empty($data['simulation']))
<div class="alert alert-warning">SIMULASI LOCAL — tiada transaksi sebenar dihantar ke Shoraka.</div>
@endif
@if($record && !empty($record->context['temporary_gadai']) && $record->teller === Auth::user()->name && $record->state === 'WAITING')
<div id="shoraka-progress" class="alert alert-info" role="status">Sedang memproses pembiayaan dan menyemak komoditi Shoraka secara automatik. Sila tunggu.</div>
<script>
(function () {
var started = Date.now();
var failures = 0;
var completing = false;
window.shorakaProgress(2, 'Order telah dihantar. Sedang menyemak pengesahan Shoraka...');
var panel = document.getElementById('shoraka-progress');
function stop(message) {
panel.className = 'alert alert-warning';
panel.textContent = message;
window.shorakaProgress(completing ? 3 : 2, message, true);
}
function poll() {
if (Date.now() - started > 300000) {
stop('Masih menunggu pengesahan Shoraka. Jangan hantar transaksi semula. Buka semula halaman ini untuk menyambung semakan.');
return;
}
fetch(@json(route('shoraka.poll', $norujukan)), {
method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-TOKEN': @json(csrf_token()), 'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify({progress: completing ? 'complete' : 'check'})
}).then(function (response) {
if (!response.ok) { throw new Error('Status unavailable'); }
return response.json();
}).then(function (data) {
failures = 0;
if (data.state === 'ready') {
completing = true;
window.shorakaProgress(3, 'Shoraka telah disahkan. Sedang menyelesaikan pembiayaan dan menyimpan gadaian...');
window.setTimeout(poll, 0);
return;
}
if (data.state === 'done') {
window.shorakaProgress(4, 'Pembiayaan berjaya diselesaikan.', true);
panel.className = 'alert alert-success';
panel.textContent = 'Pembiayaan berjaya diselesaikan.';
if (window.Swal && typeof window.Swal.fire === 'function') {
document.getElementById('shoraka-modal-close').click();
window.Swal.fire({
icon: 'success',
title: 'Pembiayaan berjaya',
text: 'Transaksi komoditi Shoraka dan proses pembiayaan telah selesai.',
confirmButtonText: 'OK',
allowOutsideClick: false,
allowEscapeKey: false
}).then(function () { window.location.assign(data.redirect); });
} else {
window.alert('Pembiayaan berjaya diselesaikan.');
window.location.assign(data.redirect);
}
return;
}
if (data.state === 'failed') { stop(data.message); return; }
window.setTimeout(poll, 5000);
}).catch(function () {
if (++failures >= 6) {
stop('Sambungan semakan terganggu. Jangan hantar transaksi semula. Muat semula halaman untuk menyambung semakan.');
return;
}
window.setTimeout(poll, 5000);
});
}
poll();
}());
</script>
@else
@if($error)<div class="alert alert-danger">{{ $error }}</div>@endif
@if(session('shoraka_message'))<div class="alert alert-info">{{ session('shoraka_message') }}</div>@endif
@php
@@ -80,6 +152,7 @@
<a class="btn btn-secondary mb-2" href="{{ route('shoraka.certificate', $norujukan) }}">Muat Turun Sijil</a>
@endif
</div>
@endif
</div>
</div>
</div>
+3
View File
@@ -420,6 +420,8 @@ Route::group(['middleware' => ['khazanah']], function() {
Route::post('/barcodescanned',['as'=>'barcode.scanned','uses'=>'KhazanahController@barcodescanned'])->middleware('auth','khazanah');
//Operasi
Route::get('/pc/shoraka', 'ShorakaController@pcIndex')->name('pc.shoraka.index')->middleware('auth', 'khazanah');
Route::get('/operasi/shoraka', 'ShorakaController@operasiIndex')->name('operasi.shoraka.index')->middleware('auth', 'operasi');
Route::get('/operasi/kelulusan','OperasiController@kelulusan')->name('operasi.kelulusan')->middleware('auth','operasi');
Route::get('/operasi/kelulusan/anggota','OperasiController@kelulusanAnggota')->name('operasi.kelulusananggota')->middleware('auth','operasi');
Route::post('/operasi/createkelulusan',['as'=>'operasi.createkelulusan','uses'=>'OperasiController@createkelulusan'])->middleware('auth','teller');
@@ -610,6 +612,7 @@ Route::group(['prefix' => 'shoraka', 'middleware' => ['auth', 'role:teller,penil
Route::get('/', 'ShorakaController@index')->name('shoraka.index');
Route::get('/{norujukan}', 'ShorakaController@show')->name('shoraka.show');
Route::post('/{norujukan}/submit', 'ShorakaController@submit')->name('shoraka.submit');
Route::post('/{norujukan}/poll', 'ShorakaController@poll')->name('shoraka.poll');
Route::post('/{norujukan}/refresh', 'ShorakaController@refresh')->name('shoraka.refresh');
Route::post('/{norujukan}/finish', 'ShorakaController@finish')->name('shoraka.finish');
Route::get('/{norujukan}/certificate', 'ShorakaController@certificate')->name('shoraka.certificate');