diff --git a/.env.docker b/.env.docker index ea8375c..7cee720 100644 --- a/.env.docker +++ b/.env.docker @@ -64,5 +64,8 @@ DB_PASSWORD_7=root TENNANT_MASTER_DB=master_erahn TENNANT_KAUNTER_DB=kaunter_erahn +KAUNTER_URL=http://host.docker.internal:8081 +INTERNAL_NOTIFICATION_SECRET=erahn-local-notification-secret + CACHE_DRIVER=file QUEUE_CONNECTION=sync \ No newline at end of file diff --git a/.env.example b/.env.example index 9f8a734..7c39266 100644 --- a/.env.example +++ b/.env.example @@ -87,172 +87,21 @@ DB_DATABASE_11=arrahn_pputeh DB_USERNAME_11=spider DB_PASSWORD_11=G0dz!ll42020 -DB_CONNECTION_12=mysql -DB_HOST_12=127.0.0.1 -DB_PORT_12=3306 -DB_DATABASE_12=arrahn_rpanjang -DB_USERNAME_12=spider -DB_PASSWORD_12=G0dz!ll42020 - -DB_CONNECTION_13=mysql -DB_HOST_13=127.0.0.1 -DB_PORT_13=3306 -DB_DATABASE_13=arrahn_tmerah -DB_USERNAME_13=spider -DB_PASSWORD_13=G0dz!ll42020 - -DB_CONNECTION_14=mysql -DB_HOST_14=127.0.0.1 -DB_PORT_14=3306 -DB_DATABASE_14=arrahn_wb -DB_USERNAME_14=spider -DB_PASSWORD_14=G0dz!ll42020 - -DB_CONNECTION_15=mysql -DB_HOST_15=127.0.0.1 -DB_PORT_15=3306 -DB_DATABASE_15=arrahn_wcy -DB_USERNAME_15=spider -DB_PASSWORD_15=G0dz!ll42020 - -DB_CONNECTION_16=mysql -DB_HOST_16=127.0.0.1 -DB_PORT_16=3306 -DB_DATABASE_16=arrahn_bentong -DB_USERNAME_16=spider -DB_PASSWORD_16=G0dz!ll42020 - -DB_CONNECTION_17=mysql -DB_HOST_17=192.168.0.102 -DB_PORT_17=3306 -DB_DATABASE_17=arrahn_kajang -DB_USERNAME_17=spider -DB_PASSWORD_17=G0dz!ll42020 - -DB_CONNECTION_18=mysql -DB_HOST_18=192.168.0.102 -DB_PORT_18=3306 -DB_DATABASE_18=arrahn_kangar -DB_USERNAME_18=spider -DB_PASSWORD_18=G0dz!ll42020 - -DB_CONNECTION_19=mysql -DB_HOST_19=192.168.0.102 -DB_PORT_19=3306 -DB_DATABASE_19=arrahn_kuantan -DB_USERNAME_19=spider -DB_PASSWORD_19=G0dz!ll42020 - -DB_CONNECTION_20=mysql -DB_HOST_20=192.168.0.102 -DB_PORT_20=3306 -DB_DATABASE_20=arrahn_pgudang -DB_USERNAME_20=spider -DB_PASSWORD_20=G0dz!ll42020 - -DB_CONNECTION_21=mysql -DB_HOST_21=192.168.0.102 -DB_PORT_21=3306 -DB_DATABASE_21=arrahn_raub -DB_USERNAME_21=spider -DB_PASSWORD_21=G0dz!ll42020 - -DB_CONNECTION_22=mysql -DB_HOST_22=192.168.0.102 -DB_PORT_22=3306 -DB_DATABASE_22=arrahn_rlaut -DB_USERNAME_22=spider -DB_PASSWORD_22=G0dz!ll42020 - -DB_CONNECTION_23=mysql -DB_HOST_23=192.168.0.102 -DB_PORT_23=3306 -DB_DATABASE_23=arrahn_skudai -DB_USERNAME_23=spider -DB_PASSWORD_23=G0dz!ll42020 - -DB_CONNECTION_24=mysql -DB_HOST_24=192.168.0.102 -DB_PORT_24=3306 -DB_DATABASE_24=arrahn_spetani -DB_USERNAME_24=spider -DB_PASSWORD_24=G0dz!ll42020 - -DB_CONNECTION_25=mysql -DB_HOST_25=192.168.0.102 -DB_PORT_25=3306 -DB_DATABASE_25=arrahn_temerloh -DB_USERNAME_25=spider -DB_PASSWORD_25=G0dz!ll42020 - -DB_CONNECTION_26=mysql -DB_HOST_26=192.168.0.103 -DB_PORT_26=3306 -DB_DATABASE_26=arrahn_api2 -DB_USERNAME_26=spider -DB_PASSWORD_26=G0dz!ll42020 - -DB_CONNECTION_27=mysql -DB_HOST_27=192.168.0.103 -DB_PORT_27=3306 -DB_DATABASE_27=arrahn_beaufort -DB_USERNAME_27=spider -DB_PASSWORD_27=G0dz!ll42020 - -DB_CONNECTION_28=mysql -DB_HOST_28=192.168.0.103 -DB_PORT_28=3306 -DB_DATABASE_28=arrahn_kbelud -DB_USERNAME_28=spider -DB_PASSWORD_28=G0dz!ll42020 - -DB_CONNECTION_29=mysql -DB_HOST_29=192.168.0.103 -DB_PORT_29=3306 -DB_DATABASE_29=arrahn_keningau -DB_USERNAME_29=spider -DB_PASSWORD_29=G0dz!ll42020 - -DB_CONNECTION_30=mysql -DB_HOST_30=192.168.0.103 -DB_PORT_30=3306 -DB_DATABASE_30=arrahn_putatan -DB_USERNAME_30=spider -DB_PASSWORD_30=G0dz!ll42020 - -DB_CONNECTION_31=mysql -DB_HOST_31=192.168.0.103 -DB_PORT_31=3306 -DB_DATABASE_31=arrahn_sandakan -DB_USERNAME_31=spider -DB_PASSWORD_31=G0dz!ll42020 - -DB_CONNECTION_32=mysql -DB_HOST_32=192.168.0.103 -DB_PORT_32=3306 -DB_DATABASE_32=arrahn_tawau -DB_USERNAME_32=spider -DB_PASSWORD_32=G0dz!ll42020 - -DB_CONNECTION_33=mysql -DB_HOST_33=192.168.0.103 -DB_PORT_33=3306 -DB_DATABASE_33=arrahn_tuaran -DB_USERNAME_33=spider -DB_PASSWORD_33=G0dz!ll42020 - CACHE_DRIVER=file QUEUE_CONNECTION=sync +MAX_MUTUEMAS=true -WABOT_API=https://md.wabot.my/api/send.php -WABOT_INSTANCES_ID=63F40BD120DB4 -# WABOT_INSTANCES_ID=64892FD7440A6 -WABOT_TOKEN_KEYS=cbee7e95cf3ffbfa0531d5fddfd44f87 +TENNANT_MASTER_DB=master_erahn +TENNANT_KAUNTER_DB=kaunter_erahn +KAUNTER_URL=http://host.docker.internal:8081 +INTERNAL_NOTIFICATION_SECRET= -ONEWAYSMS_API=http://gateway.onewaysms.com.my:10001/api.aspx -ONEWAYSMS_AUTH_USER=APIUK330HDAFO -ONEWAYSMS_AUTH_PASS=APIUK330HDAFOUK330 - -ADMIN_PHONE_NUMBER=60178916936 -ACTIVE_JOB_SERVER=false +WHATSAPP_TOKEN= # Paste token from Graph API explorer. +WHATSAPP_PHONE_NUMBER_ID=1323449077519027 +WHATSAPP_API_VERSION=v25.0 +WHATSAPP_TEMPLATE=hello_world +WHATSAPP_TEMPLATE_LANG=en_US +WHATSAPP_VERIFY_TOKEN= # webhook verify token +WHATSAPP_APP_SECRET= # X-Hub-Signature-256 checks; need to be enable first +ADMIN_PHONE_NUMBER=60193450165 \ No newline at end of file diff --git a/.env.production b/.env.production index 90443a6..ec105d3 100644 --- a/.env.production +++ b/.env.production @@ -59,9 +59,29 @@ DB_DATABASE_7=master_erahn DB_USERNAME_7=spider DB_PASSWORD_7=arrahn@123 -MAX_MUTUEMAS=true +DB_CONNECTION_9=mysql +DB_HOST_9=192.168.1.216 +DB_PORT_9=3306 +DB_DATABASE_9=annur_tampoi +DB_USERNAME_9=spider +DB_PASSWORD_9=arrahn@123 CACHE_DRIVER=file QUEUE_CONNECTION=sync -TENNANT_MASTER_DB=master_erahn \ No newline at end of file +MAX_MUTUEMAS=true + +TENNANT_MASTER_DB=master_erahn +TENNANT_KAUNTER_DB=kaunter_erahn + +KAUNTER_URL=https://ujrah.erahn.com.my +INTERNAL_NOTIFICATION_SECRET="nIDTe(u@6[)VLJyd" + +WHATSAPP_TOKEN=EAAZAonT56AO0BSlxZAKa8QX6HIWJpp5Czsz2KQ8BYE4iyZAe5fg8ZAmaSwZCXZCPf44xazDZAhaXLHJKHKUrUZCxei86EWl6Ar3RingfybZAlXp79Yd6WwOG5IZAnu9o5mevm895ygrs39H11G01fpYU2lYRpIWdn6URkTA0kMJdqZAlTaRILTjUM5KhvV05Q4lLAZDZD +WHATSAPP_PHONE_NUMBER_ID=1323449077519027 +WHATSAPP_API_VERSION=v25.0 +WHATSAPP_TEMPLATE=pembiayaan_online_paid +WHATSAPP_TEMPLATE_LANG=ms +WHATSAPP_VERIFY_TOKEN=418ed86d49422071681c76bed25ae1cb7e90456c794a92f1c9ea417945a35a0a +WHATSAPP_APP_SECRET= +ADMIN_PHONE_NUMBER=60193450165 \ No newline at end of file diff --git a/app/Console/Commands/WhatsappTestSend.php b/app/Console/Commands/WhatsappTestSend.php new file mode 100644 index 0000000..44fb700 --- /dev/null +++ b/app/Console/Commands/WhatsappTestSend.php @@ -0,0 +1,55 @@ +isConfigured()) { + $this->error('Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID in .env first.'); + return 1; + } + + $to = $client->normalizeMsisdn($this->option('to') ?: env('ADMIN_PHONE_NUMBER')); + if (!$to) { + $this->error('Pass --to=60193450165 or set ADMIN_PHONE_NUMBER.'); + return 1; + } + + $template = config('whatsapp.template', 'hello_world'); + $lang = config('whatsapp.template_lang', 'en_US'); + $bodyParams = []; + + if (strtolower($template) !== 'hello_world') { + $bodyParams = [ + 'Tampoi', + 'TEST-SAG', + '100.00', + 'Maybank ****1234', + date('d/m/Y'), + 'TEST-REF', + ]; + } + + $this->info("Sending {$template} ({$lang}) to {$to} …"); + $response = $client->sendTemplate($to, $template, $lang, $bodyParams); + + if ($response->successful()) { + $this->info('OK ' . $response->status() . ' ' . $response->body()); + return 0; + } + + $this->error('FAIL ' . $response->status() . ' ' . $response->body()); + return 1; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9308414..6723bdd 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -24,6 +24,7 @@ class Kernel extends ConsoleKernel Commands\ClearCachesAll::class, Commands\RestartSupervisor::class, Commands\InsertSempornaData::class, + Commands\WhatsappTestSend::class, //Commands\ImportCsv::class, ]; diff --git a/app/Http/Controllers/KelulusanController.php b/app/Http/Controllers/KelulusanController.php index 89e7647..87cb123 100644 --- a/app/Http/Controllers/KelulusanController.php +++ b/app/Http/Controllers/KelulusanController.php @@ -8,6 +8,7 @@ use App\Cawangan; use App\Mohonid; use App\MohonAnggota; use App\Customer; +use App\Services\StaffNotificationClient; use Carbon\Carbon; @@ -444,6 +445,8 @@ public function create(REQUEST $request,$kodcaw,$norujukan,$roles) $audit->updated_at = $harini; $audit->save(); + $this->notifyOperasiAssignAnggota($kodcaw, $cawangan, $kelulusan, $request); + // ✅ Return JSON response ke frontend return response()->json([ 'success' => true, @@ -466,6 +469,58 @@ public function create(REQUEST $request,$kodcaw,$norujukan,$roles) } } + /** + * Inbox for all operasi staff. Failures must not block the teller request. + * + * @param string $kodcaw + * @param \App\Cawangan $cawangan + * @param \App\MohonAnggota $kelulusan + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function notifyOperasiAssignAnggota($kodcaw, $cawangan, $kelulusan, Request $request) + { + try { + $nama = trim((string) ($request->nama ?? '')); + if ($nama === '' && !empty($kelulusan->nokp)) { + $customer = Customer::on('mysql7') + ->where('kpbaru', $kelulusan->nokp) + ->orWhere('kplama', $kelulusan->nokp) + ->first(); + if ($customer && !empty($customer->nama)) { + $nama = trim((string) $customer->nama); + } + } + + $namaCaw = $kodcaw; + if ($cawangan) { + $resolved = trim((string) $cawangan->nama_cawangan); + if ($resolved !== '') { + $namaCaw = $resolved; + } + } + + app(StaffNotificationClient::class)->notify([ + 'type' => 'anggota.pending_operasi', + 'cawangan' => $kodcaw, + 'audience' => ['role' => 'operasi'], + 'data' => [ + 'id' => $kelulusan->id, + 'nokp' => $kelulusan->nokp, + 'nama' => $nama, + 'teller' => $kelulusan->teller, + 'komen' => $kelulusan->komen, + 'cawangan_nama' => $namaCaw, + ], + ]); + } catch (\Throwable $e) { + Log::warning('Assign anggota operasi notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'nokp' => isset($kelulusan->nokp) ? $kelulusan->nokp : null, + ]); + } + } + //KEPOHONAN EDIT DAN HAPUS public function kepohonancreate(REQUEST $request,$kodcaw,$norujukan) @@ -600,26 +655,86 @@ public function create(REQUEST $request,$kodcaw,$norujukan,$roles) return response()->json($arraykelulusan,200); } - public function listoperasikelulusan() + public function listoperasikelulusan(Request $request) { - $harini = Carbon::now(); - $harini = new Carbon; + $dateFrom = $request->query('date_from'); + $dateTo = $request->query('date_to'); + $search = trim((string) $request->query('search', '')); + + // Default to last 30 days to avoid loading entire history + if (!$dateFrom && !$dateTo) { + $dateTo = Carbon::now()->toDateString(); + $dateFrom = Carbon::now()->subDays(30)->toDateString(); + } + + $cawangan_all = Cawangan::on('mysql7') + ->select('mysql', 'details_caw', 'kodcaw') + ->get(); - $cawangan_all = Cawangan::on('mysql7')->get(); $arraykelulusan = []; - foreach($cawangan_all as $index=>$cawangan){ - $kelulusan=Mohon::on($cawangan['mysql']) - ->where('peloperasi','=','operasi') - ->where('operasi','!=','') - ->get(); + foreach ($cawangan_all as $cawangan) { + $query = Mohon::on($cawangan['mysql']) + ->select([ + 'tarikh', + 'norujukan', + 'komen', + 'jumpinj', + 'nilaimarhun', + 'berat', + 'margin', + 'status', + 'operasi', + 'masalulus', + ]) + ->where('peloperasi', '=', 'operasi') + ->where('operasi', '!=', ''); - if(sizeof($kelulusan) != 0){ - array_push($arraykelulusan,["kelulusanall" => $kelulusan,"cawangan" => $cawangan['details_caw'],"cawangankod" => $cawangan['kodcaw']]); + if ($dateFrom) { + $query->where('tarikh', '>=', $dateFrom); + } + if ($dateTo) { + $query->where('tarikh', '<=', $dateTo); + } + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('norujukan', 'like', "%{$search}%") + ->orWhere('operasi', 'like', "%{$search}%") + ->orWhere('status', 'like', "%{$search}%") + ->orWhere('komen', 'like', "%{$search}%"); + }); } - } - return response()->json($arraykelulusan,200); + $kelulusan = $query->orderBy('tarikh', 'desc')->get(); + + foreach ($kelulusan as $kel) { + $komenParts = explode('|', (string) ($kel->komen ?? '')); + $komenParts = array_values(array_filter($komenParts, function ($part) { + return $part !== ''; + })); + + $arraykelulusan[] = [ + 'tarikh' => $kel->tarikh ?? '-', + 'norujukan' => $kel->norujukan ?? '-', + 'status' => $kel->status ?? '-', + 'komen' => $komenParts[0] ?? ($kel->komen ?: '-'), + 'pinjaman' => $kel->jumpinj ?? '-', + 'nilaimarhun' => $komenParts[1] ?? ($kel->nilaimarhun ?? '-'), + 'berat' => $komenParts[2] ?? ($kel->berat ?? '-'), + 'margin' => $komenParts[3] ?? ($kel->margin ?? '-'), + 'pelulus' => $kel->operasi ?? '-', + 'masalulus' => $kel->masalulus ?? '-', + 'cawangan' => $cawangan['details_caw'], + 'cawangankod' => $cawangan['kodcaw'], + ]; + } + } + + usort($arraykelulusan, function ($a, $b) { + return strcmp((string) ($b['tarikh'] ?? ''), (string) ($a['tarikh'] ?? '')); + }); + + return response()->json($arraykelulusan, 200); } public function UpdateHapusAnsuranOperasi(Request $request){ diff --git a/app/Http/Controllers/TunaiController.php b/app/Http/Controllers/TunaiController.php index 12d0fba..05e55b9 100644 --- a/app/Http/Controllers/TunaiController.php +++ b/app/Http/Controllers/TunaiController.php @@ -13,6 +13,7 @@ use App\Vault; use Auth0\SDK\API\Management\Users; use Illuminate\Http\Request; use Carbon\Carbon; +use App\Services\StaffNotificationClient; use Illuminate\Support\Facades\Log; class TunaiController extends Controller { @@ -476,10 +477,54 @@ class TunaiController extends Controller $audit->updated_at = $current; $audit->save(); + $this->notifyKhazanahAliranTunai($kodcaw, $request, $reqteller); return response()->json($reqteller,200); } + /** + * Inbox for PC/PPC at the branch. Failures must not block the request. + * + * @param string $kodcaw + * @param \Illuminate\Http\Request $request + * @param \App\RequestTeller $reqteller + * @return void + */ + protected function notifyKhazanahAliranTunai($kodcaw, Request $request, $reqteller) + { + try { + $cawangan = Cawangan::on('mysql7')->where('kodcaw', '=', $kodcaw)->first(); + $namaCaw = $kodcaw; + if ($cawangan) { + $nama = trim((string) $cawangan->nama_cawangan); + if ($nama !== '') { + $namaCaw = $nama; + } + } + + $modal = (float) $request->modal; + $serahan = (float) $request->serahan; + + app(StaffNotificationClient::class)->notify([ + 'type' => 'aliran_tunai.pending_khazanah', + 'cawangan' => $kodcaw, + 'audience' => ['role' => 'pc'], + 'data' => [ + 'request_no' => isset($reqteller->no) ? $reqteller->no : null, + 'kodlaluan' => $request->kodlaluan, + 'modal' => $modal, + 'serahan' => $serahan, + 'cawangan_nama' => $namaCaw, + ], + ]); + } catch (\Throwable $e) { + Log::warning('Aliran tunai khazanah notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'kodlaluan' => $request->kodlaluan, + ]); + } + } + public function getTunaiSemalam($kodcaw) { $current = new Carbon(); diff --git a/app/Http/Controllers/WhatsappWebhookController.php b/app/Http/Controllers/WhatsappWebhookController.php new file mode 100644 index 0000000..11b4f40 --- /dev/null +++ b/app/Http/Controllers/WhatsappWebhookController.php @@ -0,0 +1,122 @@ +query('hub_mode', $request->query('hub.mode')); + $token = $request->query('hub_verify_token', $request->query('hub.verify_token')); + $challenge = $request->query('hub_challenge', $request->query('hub.challenge')); + + $expected = (string) config('whatsapp.verify_token'); + + if ($mode === 'subscribe' && $expected !== '' && hash_equals($expected, (string) $token)) { + Log::info('WhatsApp webhook verified'); + return response((string) $challenge, 200) + ->header('Content-Type', 'text/plain'); + } + + Log::warning('WhatsApp webhook verify failed', [ + 'mode' => $mode, + 'token_match' => $expected !== '' && hash_equals($expected, (string) $token), + ]); + + return response('Forbidden', 403); + } + + /** + * POST /api/whatsapp/webhook + * Acknowledge quickly; optionally check X-Hub-Signature-256 when app secret is set. + */ + public function handle(Request $request) + { + $raw = $request->getContent(); + + if (!$this->signatureOk($request, $raw)) { + Log::warning('WhatsApp webhook invalid signature'); + return response('Invalid signature', 403); + } + + $payload = json_decode($raw, true); + if (!is_array($payload)) { + return response('OK', 200); + } + + try { + $this->logEvents($payload); + } catch (\Throwable $e) { + Log::error('WhatsApp webhook log failed: ' . $e->getMessage()); + } + + return response('OK', 200); + } + + protected function signatureOk(Request $request, $raw) + { + $secret = trim((string) config('whatsapp.app_secret')); + if ($secret === '') { + return true; + } + + $header = (string) $request->header('X-Hub-Signature-256', ''); + if ($header === '' || strpos($header, 'sha256=') !== 0) { + return false; + } + + $expected = 'sha256=' . hash_hmac('sha256', $raw, $secret); + + return hash_equals($expected, $header); + } + + protected function logEvents(array $payload) + { + $entries = $payload['entry'] ?? []; + if (!is_array($entries)) { + return; + } + + foreach ($entries as $entry) { + $changes = $entry['changes'] ?? []; + if (!is_array($changes)) { + continue; + } + + foreach ($changes as $change) { + $value = $change['value'] ?? []; + if (!is_array($value)) { + continue; + } + + foreach ($value['statuses'] ?? [] as $status) { + Log::info('WhatsApp status', [ + 'id' => $status['id'] ?? null, + 'status' => $status['status'] ?? null, + 'recipient' => $status['recipient_id'] ?? null, + 'errors' => $status['errors'] ?? null, + ]); + } + + foreach ($value['messages'] ?? [] as $message) { + Log::info('WhatsApp inbound', [ + 'from' => $message['from'] ?? null, + 'type' => $message['type'] ?? null, + 'id' => $message['id'] ?? null, + ]); + } + } + } + } +} diff --git a/app/Jobs/WhatsappPembiayaanOnlinePaid.php b/app/Jobs/WhatsappPembiayaanOnlinePaid.php new file mode 100644 index 0000000..3ec528d --- /dev/null +++ b/app/Jobs/WhatsappPembiayaanOnlinePaid.php @@ -0,0 +1,151 @@ +kodcaw = $data['kodcaw'] ?? null; + $this->cawName = $data['caw_name'] ?? null; + $this->nokp = $data['nokp'] ?? null; + $this->norujukan = $data['norujukan'] ?? null; + $this->pinjaman = $data['pinjaman'] ?? null; + $this->namaBank = $data['nama_bank'] ?? null; + $this->noakaun = $data['noakaun'] ?? null; + $this->tarikhBayaran = $data['tarikh_bayaran'] ?? null; + $this->noRujukanBayaran = $data['no_rujukan_bayaran'] ?? null; + } + + public function handle() + { + $client = new WhatsappCloudClient(); + $now = Carbon::now(); + + if (!$client->isConfigured()) { + Log::warning('WhatsApp Cloud skipped: token or phone_number_id missing', [ + 'norujukan' => $this->norujukan, + ]); + return; + } + + $customer = Customer::on('mysql7') + ->where(function ($q) { + $q->where('kpbaru', $this->nokp)->orWhere('kplama', $this->nokp); + }) + ->first(); + + $storedPhone = $customer ? ($customer->telefon ?: $customer->telefon2) : null; + $to = $client->normalizeMsisdn($storedPhone); + + if (env('APP_ENV') !== 'production') { + $admin = $client->normalizeMsisdn(env('ADMIN_PHONE_NUMBER')); + if (!$admin) { + Log::warning('WhatsApp Cloud skipped: ADMIN_PHONE_NUMBER required when not production', [ + 'norujukan' => $this->norujukan, + ]); + $this->logBlast($now, $storedPhone, 'skipped: ADMIN_PHONE_NUMBER empty'); + return; + } + $to = $admin; + } + + if (!$to) { + Log::warning('WhatsApp Cloud skipped: no phone', [ + 'nokp' => $this->nokp, + 'norujukan' => $this->norujukan, + ]); + $this->logBlast($now, $storedPhone, 'skipped: no phone'); + return; + } + + $template = config('whatsapp.template', 'hello_world'); + $lang = config('whatsapp.template_lang', 'en_US'); + $bodyParams = $this->isHelloWorld($template) ? [] : $this->bodyParams(); + + try { + $response = $client->sendTemplate($to, $template, $lang, $bodyParams); + $this->logBlast($now, $to, $response->body()); + } catch (\Throwable $e) { + Log::error('WhatsappPembiayaanOnlinePaid failed: ' . $e->getMessage(), [ + 'norujukan' => $this->norujukan, + ]); + $this->logBlast($now, $to, 'exception: ' . $e->getMessage()); + } + } + + protected function isHelloWorld($template) + { + return strtolower((string) $template) === 'hello_world'; + } + + /** + * Matches pembiayaan_online_paid body: cawangan, SAG, amaun, akaun, tarikh, rujukan. + * + * @return array + */ + protected function bodyParams() + { + $akaun = trim((string) $this->namaBank); + $tail = $this->accountTail($this->noakaun); + if ($tail !== '') { + $akaun = trim($akaun . ' ' . $tail); + } + + $tarikh = $this->tarikhBayaran + ? Carbon::parse($this->tarikhBayaran)->format('d/m/Y') + : Carbon::now()->format('d/m/Y'); + + return [ + $this->cawName ?: $this->kodcaw, + $this->norujukan ?: '-', + number_format((float) $this->pinjaman, 2, '.', ''), + $akaun !== '' ? $akaun : '-', + $tarikh, + $this->noRujukanBayaran ?: '-', + ]; + } + + protected function accountTail($noakaun) + { + $digits = preg_replace('/\D+/', '', (string) $noakaun); + if ($digits === '') { + return ''; + } + + return '****' . substr($digits, -4); + } + + protected function logBlast($now, $telefon, $status) + { + try { + $log = new BlastWasap(); + $log->kodcaw = $this->kodcaw; + $log->nokp = $this->nokp; + $log->notelefon = $telefon; + $log->norujukan = $this->norujukan; + $log->status = is_string($status) ? $status : json_encode($status); + $log->created_at = $now; + $log->updated_at = $now; + $log->save(); + } catch (\Throwable $e) { + Log::error('log_blast_wasap save failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Services/PembiayaanOnline/ModalOnlineService.php b/app/Services/PembiayaanOnline/ModalOnlineService.php index 6579b6d..b27a230 100644 --- a/app/Services/PembiayaanOnline/ModalOnlineService.php +++ b/app/Services/PembiayaanOnline/ModalOnlineService.php @@ -6,6 +6,7 @@ use App\Cawangan; use App\Feature; use App\ModalOnline; use App\RequestModalOnline; +use App\Services\StaffNotificationClient; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -362,6 +363,8 @@ class ModalOnlineService 'nota' => $nota, ]); + $this->notifyOperasiModalRequest($kodcaw, $request, $jenis); + return [ 'ok' => true, 'status' => 201, @@ -373,6 +376,54 @@ class ModalOnlineService ]; } + /** + * Inbox for all operasi staff. Failures must not block the request. + * + * @param string $kodcaw + * @param \App\RequestModalOnline $request + * @param string $jenis + * @return void + */ + protected function notifyOperasiModalRequest($kodcaw, $request, $jenis) + { + try { + app(StaffNotificationClient::class)->notify([ + 'type' => 'modal_online.pending_operasi', + 'cawangan' => $kodcaw, + 'audience' => ['role' => 'operasi'], + 'data' => [ + 'request_id' => $request->id, + 'amaun' => (float) $request->amaun, + 'dimohon_oleh' => $request->dimohon_oleh, + 'jenis' => $jenis, + 'nota' => $request->nota, + 'cawangan_nama' => $this->cawanganNama($kodcaw), + ], + ]); + } catch (\Throwable $e) { + Log::warning('Operasi modal-online notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'request_id' => $request->id, + ]); + } + } + + /** + * @param string $kodcaw + * @return string + */ + protected function cawanganNama($kodcaw) + { + $cawangan = Cawangan::on('mysql7')->where('kodcaw', '=', $kodcaw)->first(); + if (!$cawangan) { + return $kodcaw; + } + + $nama = trim((string) $cawangan->nama_cawangan); + + return $nama !== '' ? $nama : $kodcaw; + } + /** * List top-up requests. * For PC: pass kodcaw. For Operasi: omit kodcaw (all branches). diff --git a/app/Services/PembiayaanOnline/PembiayaanOnlineService.php b/app/Services/PembiayaanOnline/PembiayaanOnlineService.php index 27c2c24..44298aa 100644 --- a/app/Services/PembiayaanOnline/PembiayaanOnlineService.php +++ b/app/Services/PembiayaanOnline/PembiayaanOnlineService.php @@ -9,7 +9,10 @@ use App\Cawangan; use App\Customer; use App\Feature; use App\Gadai; +use App\Jobs\WhatsappPembiayaanOnlinePaid; use App\PembiayaanOnline; +use App\Services\StaffNotificationClient; +use App\Services\Whatsapp\WhatsappCloudClient; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -747,7 +750,7 @@ class PembiayaanOnlineService $tarikh = $now->toDateString(); try { - return DB::connection($mysql)->transaction(function () use ( + $result = DB::connection($mysql)->transaction(function () use ( $mysql, $kodcaw, $ids, @@ -855,6 +858,127 @@ class PembiayaanOnlineService 'payload' => ['message' => 'Gagal hantar batch pembiayaan online'], ]; } + + if (!empty($result['ok'])) { + $this->notifyOperasiBatchSubmitted($kodcaw, $ids, $pcBy, $result['payload']); + } + + return $result; + } + + /** + * Inbox for all operasi staff after PC submit. Failures must not block the batch. + * + * @param string $kodcaw + * @param array $ids + * @param string $pcBy + * @param array $payload + * @return void + */ + protected function notifyOperasiBatchSubmitted($kodcaw, array $ids, $pcBy, array $payload) + { + $batch = isset($payload['batch']) ? $payload['batch'] : null; + if (!$batch) { + return; + } + + $batchId = is_array($batch) ? ($batch['id'] ?? null) : $batch->id; + $batchNo = is_array($batch) ? ($batch['batch_no'] ?? '') : $batch->batch_no; + $slot = is_array($batch) ? ($batch['slot'] ?? '') : $batch->slot; + $bilangan = is_array($batch) ? ($batch['bilangan'] ?? 0) : $batch->bilangan; + $jumlah = is_array($batch) ? ($batch['jumlah_keseluruhan'] ?? 0) : $batch->jumlah_keseluruhan; + + if (!$batchId) { + return; + } + + try { + app(StaffNotificationClient::class)->notify([ + 'type' => 'pembiayaan_online.batch_operasi', + 'cawangan' => $kodcaw, + 'audience' => ['role' => 'operasi'], + 'data' => [ + 'batch_id' => $batchId, + 'batch_no' => $batchNo, + 'slot' => $slot, + 'appended' => !empty($payload['appended']), + 'submitted_count' => count($ids), + 'bilangan' => $bilangan, + 'jumlah_keseluruhan' => (float) $jumlah, + 'pc_submitted_by' => $pcBy, + 'cawangan_nama' => $this->cawanganNama($kodcaw), + ], + ]); + } catch (\Throwable $e) { + Log::warning('Operasi batch notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'batch_id' => $batchId, + ]); + } + } + + /** + * @param string $kodcaw + * @return string + */ + protected function cawanganNama($kodcaw) + { + $cawangan = Cawangan::on('mysql7')->where('kodcaw', '=', $kodcaw)->first(); + if (!$cawangan) { + return $kodcaw; + } + + $nama = trim((string) $cawangan->nama_cawangan); + + return $nama !== '' ? $nama : $kodcaw; + } + + /** + * Inbox for PC at this branch. Failures must not block the operasi action. + * + * @param string $kodcaw + * @param string $type + * @param array $data + * @return void + */ + protected function notifyPcPembiayaanOnline($kodcaw, $type, array $data) + { + try { + app(StaffNotificationClient::class)->notify([ + 'type' => $type, + 'cawangan' => $kodcaw, + 'audience' => ['role' => 'pc'], + 'data' => array_merge($data, [ + 'cawangan_nama' => $this->cawanganNama($kodcaw), + ]), + ]); + } catch (\Throwable $e) { + Log::warning('PC pembiayaan-online notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'type' => $type, + ]); + } + } + + /** + * @param mixed $line + * @return array + */ + protected function lineNotifyFields($line) + { + if (!$line) { + return []; + } + + $isArr = is_array($line); + + return [ + 'norujukan' => $isArr ? ($line['norujukan'] ?? '') : $line->norujukan, + 'nama_pelanggan' => $isArr ? ($line['nama_pelanggan'] ?? '') : $line->nama_pelanggan, + 'pinjaman' => $isArr ? ($line['pinjaman'] ?? 0) : $line->pinjaman, + 'ops_by' => $isArr ? ($line['ops_by'] ?? '') : $line->ops_by, + 'reject_reason' => $isArr ? ($line['reject_reason'] ?? '') : $line->reject_reason, + ]; } /** @@ -1029,7 +1153,7 @@ class PembiayaanOnlineService $now = Carbon::now(); try { - return DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $id, $opsBy, $reason, $now) { + $result = DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $id, $opsBy, $reason, $now) { $line = PembiayaanOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('id', $id) @@ -1100,6 +1224,18 @@ class PembiayaanOnlineService 'payload' => ['message' => 'Gagal tolak pembiayaan online'], ]; } + + if (!empty($result['ok'])) { + $this->notifyPcPembiayaanOnline($kodcaw, 'pembiayaan_online.rejected_pc', array_merge( + $this->lineNotifyFields(isset($result['payload']['pembiayaan_online']) ? $result['payload']['pembiayaan_online'] : null), + [ + 'ops_by' => $opsBy, + 'reject_reason' => $reason, + ] + )); + } + + return $result; } /** @@ -1220,7 +1356,7 @@ class PembiayaanOnlineService } try { - return DB::connection($mysql)->transaction(function () use ( + $result = DB::connection($mysql)->transaction(function () use ( $mysql, $kodcaw, $id, @@ -1319,6 +1455,18 @@ class PembiayaanOnlineService 'payload' => ['message' => 'Gagal tanda PAID / muat naik bukti'], ]; } + + if (!empty($result['ok'])) { + $this->notifyPcPembiayaanOnline($kodcaw, 'pembiayaan_online.paid_pc', array_merge( + $this->lineNotifyFields(isset($result['payload']['pembiayaan_online']) ? $result['payload']['pembiayaan_online'] : null), + [ + 'paid_count' => 1, + 'ops_by' => $opsBy, + ] + )); + } + + return $result; } /** @@ -1364,7 +1512,7 @@ class PembiayaanOnlineService } try { - return DB::connection($mysql)->transaction(function () use ( + $result = DB::connection($mysql)->transaction(function () use ( $mysql, $kodcaw, $batchId, @@ -1456,6 +1604,19 @@ class PembiayaanOnlineService 'payload' => ['message' => 'Gagal tanda PAID batch'], ]; } + + if (!empty($result['ok'])) { + $batch = isset($result['payload']['batch']) ? $result['payload']['batch'] : null; + $this->notifyPcPembiayaanOnline($kodcaw, 'pembiayaan_online.paid_pc', [ + 'paid_count' => isset($result['payload']['dibayar']) ? (int) $result['payload']['dibayar'] : 0, + 'batch_id' => $batch ? (is_array($batch) ? ($batch['id'] ?? null) : $batch->id) : $batchId, + 'batch_no' => $batch ? (is_array($batch) ? ($batch['batch_no'] ?? '') : $batch->batch_no) : '', + 'jumlah_keseluruhan' => $batch ? (is_array($batch) ? ($batch['jumlah_keseluruhan'] ?? 0) : $batch->jumlah_keseluruhan) : 0, + 'ops_by' => $opsBy, + ]); + } + + return $result; } /** @@ -1613,8 +1774,8 @@ class PembiayaanOnlineService } /** - * PC marks PAID line(s) as tallied / blast done (blast_sent=1). - * Actual WhatsApp/SMS dispatch can be wired later. + * PC marks PAID line(s) as tallied / blast done (blast_sent=1), + * then queues WhatsApp Cloud template to each customer (or ADMIN_PHONE_NUMBER off-prod). * * @param array|int|string $ids * @return array{ok:bool,status:int,payload:mixed} @@ -1643,7 +1804,7 @@ class PembiayaanOnlineService $updated = 0; try { - return DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $ids, $blastBy, $now, &$updated) { + $result = DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $ids, $blastBy, $now, &$updated) { $lines = PembiayaanOnline::on($mysql) ->where('kodcaw', $kodcaw) ->whereIn('id', $ids) @@ -1697,6 +1858,50 @@ class PembiayaanOnlineService 'payload' => ['message' => 'Gagal tanda blast/tally'], ]; } + + if (!empty($result['ok'])) { + $this->queuePaidWhatsapp($kodcaw, $result['payload']['data'] ?? []); + } + + return $result; + } + + /** + * Dispatch Cloud API jobs after tally commit. Failures are logged; tally stays PAID/blast_sent. + * + * @param \Illuminate\Support\Collection|array $lines + */ + protected function queuePaidWhatsapp($kodcaw, $lines) + { + $client = new WhatsappCloudClient(); + if (!$client->isConfigured()) { + Log::info('WhatsApp Cloud not configured; tally saved without send', ['kodcaw' => $kodcaw]); + return; + } + + $cawangan = Cawangan::on('mysql7')->where('kodcaw', $kodcaw)->first(); + $cawName = $cawangan ? $cawangan->nama_cawangan : $kodcaw; + + foreach ($lines as $line) { + try { + dispatch(new WhatsappPembiayaanOnlinePaid([ + 'kodcaw' => $kodcaw, + 'caw_name' => $cawName, + 'nokp' => $line->nokp ?? null, + 'norujukan' => $line->norujukan ?? null, + 'pinjaman' => $line->pinjaman ?? null, + 'nama_bank' => $line->nama_bank ?? null, + 'noakaun' => $line->noakaun ?? null, + 'tarikh_bayaran' => $line->tarikh_bayaran ?? null, + 'no_rujukan_bayaran' => $line->no_rujukan_bayaran ?? null, + ])); + } catch (\Throwable $e) { + Log::error('queuePaidWhatsapp dispatch failed: ' . $e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'id' => $line->id ?? null, + ]); + } + } } /** @@ -1773,40 +1978,88 @@ class PembiayaanOnlineService return BatchPembiayaanOnline::on($mysql)->where('id', $batchId)->first(); } - public function suggestSlot() + public function slotJadual() { - $now = Carbon::now(); - $mins = ((int) $now->format('G')) * 60 + (int) $now->format('i'); - - if ($mins < 11 * 60) { - return [ + return [ + [ 'slot' => 'BATCH1', + 'label' => 'Batch 1', + 'mula' => '00:00', 'cutoff' => '11:00', 'proses' => '12:30', - 'lewat' => false, - 'nota' => 'Dalam waktu Batch 1', - ]; + ], + [ + 'slot' => 'BATCH2', + 'label' => 'Batch 2', + 'mula' => '11:00', + 'cutoff' => '16:30', + 'proses' => '16:30', + ], + ]; + } + + public function suggestSlot() + { + $now = Carbon::now('Asia/Kuala_Lumpur'); + $mins = ((int) $now->format('G')) * 60 + (int) $now->format('i'); + $jadual = $this->slotJadual(); + $batch1 = $jadual[0]; + $batch2 = $jadual[1]; + $cutoff1 = $this->hmToMinutes($batch1['cutoff']); + $cutoff2 = $this->hmToMinutes($batch2['cutoff']); + + if ($mins < $cutoff1) { + return $this->slotPayload($batch1, false, $cutoff1 - $mins, 'Dalam waktu Batch 1', $now, $jadual); } - if ($mins < 15 * 60) { - return [ - 'slot' => 'BATCH2', - 'cutoff' => '15:00', - 'proses' => '16:30', - 'lewat' => false, - 'nota' => 'Dalam waktu Batch 2', - ]; + if ($mins < $cutoff2) { + return $this->slotPayload($batch2, false, $cutoff2 - $mins, 'Dalam waktu Batch 2', $now, $jadual); } + $lewat = $this->slotPayload( + $batch2, + true, + 0, + 'Selepas cutoff Batch 2 — masih boleh hantar; Operasi mungkin proses esok', + $now, + $jadual + ); + $lewat['proses'] = $batch2['proses'] . ' (hari berikutnya jika sudah lewat)'; + + return $lewat; + } + + protected function slotPayload(array $batch, $lewat, $bakiMinit, $nota, Carbon $now, array $jadual) + { + $parts = explode(':', (string) $batch['cutoff']); + $cutoffAt = $now->copy()->setTime( + isset($parts[0]) ? (int) $parts[0] : 0, + isset($parts[1]) ? (int) $parts[1] : 0, + 0 + ); + return [ - 'slot' => 'BATCH2', - 'cutoff' => '15:00', - 'proses' => '16:30 (hari berikutnya jika sudah lewat)', - 'lewat' => true, - 'nota' => 'Selepas cutoff Batch 2 — masih boleh hantar; Operasi mungkin proses esok', + 'slot' => $batch['slot'], + 'cutoff' => $batch['cutoff'], + 'cutoff_at' => $cutoffAt->toIso8601String(), + 'server_now' => $now->toIso8601String(), + 'proses' => $batch['proses'], + 'lewat' => (bool) $lewat, + 'baki_minit' => (int) $bakiMinit, + 'nota' => $nota, + 'jadual' => $jadual, ]; } + protected function hmToMinutes($hm) + { + $parts = explode(':', (string) $hm); + $h = isset($parts[0]) ? (int) $parts[0] : 0; + $m = isset($parts[1]) ? (int) $parts[1] : 0; + + return ($h * 60) + $m; + } + /** * One batch per branch / day / slot (B1-001 or B2-001). Later submits append. * diff --git a/app/Services/StaffNotificationClient.php b/app/Services/StaffNotificationClient.php new file mode 100644 index 0000000..6977808 --- /dev/null +++ b/app/Services/StaffNotificationClient.php @@ -0,0 +1,53 @@ +withHeaders([ + 'X-Internal-Token' => $secret, + 'Accept' => 'application/json', + ]) + ->asJson() + ->post($base . '/internal/notifications', $payload); + + if (!$response->successful()) { + Log::warning('Staff notification to kaunter failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + 'type' => isset($payload['type']) ? $payload['type'] : null, + ]); + } + } catch (\Throwable $e) { + Log::warning('Staff notification to kaunter errored: '.$e->getMessage(), [ + 'type' => isset($payload['type']) ? $payload['type'] : null, + ]); + } + } +} diff --git a/app/Services/Whatsapp/WhatsappCloudClient.php b/app/Services/Whatsapp/WhatsappCloudClient.php new file mode 100644 index 0000000..6c5ac2d --- /dev/null +++ b/app/Services/Whatsapp/WhatsappCloudClient.php @@ -0,0 +1,100 @@ + $bodyParams + * @return \Illuminate\Http\Client\Response + */ + public function sendTemplate($to, $templateName, $lang, array $bodyParams = []) + { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => ['code' => $lang], + ], + ]; + + if (count($bodyParams) > 0) { + $payload['template']['components'] = [[ + 'type' => 'body', + 'parameters' => array_map(function ($text) { + return [ + 'type' => 'text', + 'text' => $this->sanitizeParam($text), + ]; + }, array_values($bodyParams)), + ]]; + } + + $version = config('whatsapp.api_version', 'v25.0'); + $phoneId = config('whatsapp.phone_number_id'); + $url = 'https://graph.facebook.com/' . $version . '/' . $phoneId . '/messages'; + + $response = Http::withToken(config('whatsapp.token')) + ->acceptJson() + ->asJson() + ->post($url, $payload); + + if (!$response->successful()) { + Log::error('WhatsApp Cloud send failed', [ + 'to' => $to, + 'template' => $templateName, + 'status' => $response->status(), + 'body' => $response->json() ?: $response->body(), + ]); + } + + return $response; + } + + protected function sanitizeParam($text) + { + $text = trim(preg_replace('/\s+/', ' ', (string) $text)); + if ($text === '') { + return '-'; + } + + return mb_substr($text, 0, 200); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index e5febcc..6b99bbe 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -93,6 +93,8 @@ $app->configure('tennant_database'); $app->configure('report'); $app->configure('app_parameter'); $app->configure('parameter'); +$app->configure('whatsapp'); +$app->configure('kaunter'); /* |-------------------------------------------------------------------------- | Register Middleware diff --git a/config/kaunter.php b/config/kaunter.php new file mode 100644 index 0000000..5ba78cc --- /dev/null +++ b/config/kaunter.php @@ -0,0 +1,7 @@ + env('KAUNTER_URL', ''), + 'notification_secret' => env('INTERNAL_NOTIFICATION_SECRET', ''), + 'notification_timeout' => (int) env('KAUNTER_NOTIFICATION_TIMEOUT', 3), +]; diff --git a/config/whatsapp.php b/config/whatsapp.php new file mode 100644 index 0000000..546b489 --- /dev/null +++ b/config/whatsapp.php @@ -0,0 +1,11 @@ + env('WHATSAPP_TOKEN'), + 'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'), + 'api_version' => env('WHATSAPP_API_VERSION', 'v25.0'), + 'template' => env('WHATSAPP_TEMPLATE', 'hello_world'), + 'template_lang' => env('WHATSAPP_TEMPLATE_LANG', 'en_US'), + 'verify_token' => env('WHATSAPP_VERIFY_TOKEN'), + 'app_secret' => env('WHATSAPP_APP_SECRET'), +]; diff --git a/routes/web.php b/routes/web.php index 39fc2c3..e76b125 100644 --- a/routes/web.php +++ b/routes/web.php @@ -699,6 +699,9 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->get('initiate/modal-online', ['uses' => 'ModalOnlineController@initiate']); // Operasi kelulusan pembiayaan online (must be before {kodcaw} routes) + $router->get('whatsapp/webhook', ['uses' => 'WhatsappWebhookController@verify']); + $router->post('whatsapp/webhook', ['uses' => 'WhatsappWebhookController@handle']); + $router->get('pembiayaan-online/operasi/batches', ['uses' => 'PembiayaanOnlineController@operasiListBatches']); $router->get('pembiayaan-online/operasi/{kodcaw}/batches/{batchId}', ['uses' => 'PembiayaanOnlineController@operasiShowBatch']); $router->post('pembiayaan-online/operasi/{kodcaw}/lines/{id}/approve', ['uses' => 'PembiayaanOnlineController@operasiApproveLine']);