where('kodcaw', '=', $kodcaw)->first(); if (!$cawangan) { return [ 'ok' => false, 'status' => 404, 'payload' => ['message' => 'Cawangan tidak dijumpai'], ]; } return [ 'ok' => true, 'status' => 200, 'payload' => null, 'mysql' => $cawangan['mysql'], ]; } protected function todayDate() { return Carbon::now()->toDateString(); } protected function findPending($kodcaw) { return RequestModalOnline::on('mysql7') ->where('kodcaw', $kodcaw) ->where('status', 'PENDING') ->first(); } protected function pendingPayload($pending) { return $pending ? $pending->toArray() : null; } protected function missingSnapshot($kodcaw, $tarikh, $pending = null) { return [ 'tarikh' => $tarikh, 'kodcaw' => $kodcaw, 'had' => 0.00, 'digunakan' => 0.00, 'baki' => 0.00, 'tambah' => 0.00, 'kurang' => 0.00, 'ditutup' => 0, 'missing' => true, 'pending_request' => $this->pendingPayload($pending), ]; } protected function recalcBaki($modal) { $modal->baki = round( (float) $modal->had + (float) $modal->tambah - (float) $modal->kurang - (float) $modal->digunakan, 2 ); } /** * Get today's modal_online row. Does not create — PC must request had from Operasi. * * @return array{ok:bool,status:int,payload:mixed} */ public function get($kodcaw) { $resolved = $this->resolveMysql($kodcaw); if (!$resolved['ok']) { return $resolved; } $mysql = $resolved['mysql']; $tarikh = $this->todayDate(); $pending = $this->findPending($kodcaw); $modal = ModalOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('tarikh', $tarikh) ->first(); if (!$modal) { return [ 'ok' => true, 'status' => 200, 'payload' => $this->missingSnapshot($kodcaw, $tarikh, $pending), ]; } $payload = $modal->toArray(); $payload['missing'] = false; $payload['pending_request'] = $this->pendingPayload($pending); return [ 'ok' => true, 'status' => 200, 'payload' => $payload, ]; } /** * @deprecated Use get(). Kept so existing callers do not auto-seed RM100k. */ public function getOrCreate($kodcaw) { return $this->get($kodcaw); } /** * Reserve amount inside an existing branch DB connection (caller may wrap in transaction). * * @return array{ok:bool,status:int,payload:mixed} */ public function reserveAmountOnConnection($mysql, $kodcaw, $amaun, $tarikh = null) { $amaun = round((float) $amaun, 2); if ($amaun <= 0) { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'Amaun tidak sah'], ]; } $tarikh = $tarikh ?: Carbon::now()->toDateString(); $modal = ModalOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('tarikh', $tarikh) ->lockForUpdate() ->first(); if (!$modal) { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Modal online belum diwujudkan. Sila mohon had daripada Operasi terlebih dahulu.', 'missing' => true, ], ]; } if ((int) ($modal->ditutup ?? 0) === 1) { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Modal online hari ini sudah ditutup (EOD)', 'modal_online' => $modal, ], ]; } if ((float) $modal->baki < $amaun) { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Baki modal online tidak mencukupi', 'baki' => (float) $modal->baki, 'amaun' => $amaun, ], ]; } $modal->digunakan = round((float) $modal->digunakan + $amaun, 2); $modal->baki = round( (float) $modal->had + (float) $modal->tambah - (float) $modal->kurang - (float) $modal->digunakan, 2 ); $modal->save(); return [ 'ok' => true, 'status' => 200, 'payload' => $modal, ]; } /** * Reserve amount from today's online float (transactional). * * @return array{ok:bool,status:int,payload:mixed} */ public function reserve($kodcaw, $amaun) { $resolved = $this->resolveMysql($kodcaw); if (!$resolved['ok']) { return $resolved; } $mysql = $resolved['mysql']; $amaun = round((float) $amaun, 2); if ($amaun <= 0) { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'Amaun tidak sah'], ]; } try { return DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $amaun) { return $this->reserveAmountOnConnection($mysql, $kodcaw, $amaun); }); } catch (\Throwable $e) { Log::error('ModalOnline reserve failed: ' . $e->getMessage()); return [ 'ok' => false, 'status' => 500, 'payload' => ['message' => 'Gagal reserve modal online'], ]; } } /** * Had is no longer auto-seeded. Returns how many branches already have a row today. * * @return array{ok:bool,status:int,payload:mixed} */ public function initiate($zone = 'all') { $current = $this->todayDate(); if ($zone == 'all') { $cawanganAll = Cawangan::on('mysql7')->get(); } else { $cawanganAll = Cawangan::on('mysql7')->where('zone', $zone)->get(); } $existing = 0; $missing = 0; foreach ($cawanganAll as $cawangan) { try { $exists = ModalOnline::on($cawangan['mysql']) ->where('kodcaw', $cawangan['kodcaw']) ->where('tarikh', $current) ->exists(); if ($exists) { $existing++; } else { $missing++; } } catch (\Throwable $e) { Log::warning('initiate modal_online skip ' . $cawangan['kodcaw'] . ': ' . $e->getMessage()); } } return [ 'ok' => true, 'status' => 200, 'payload' => [ 'tarikh' => $current, 'created' => 0, 'existing' => $existing, 'missing' => $missing, 'message' => 'Modal online tidak di-initiate automatik. PC perlu mohon had daripada Operasi.', ], ]; } /** * PC request opening had (or extra top-up) from Operasi. * * @return array{ok:bool,status:int,payload:mixed} */ public function requestTopUp($kodcaw, $amaun, $dimohonOleh, $nota = null) { $resolved = $this->resolveMysql($kodcaw); if (!$resolved['ok']) { return $resolved; } $mysql = $resolved['mysql']; $amaun = round((float) $amaun, 2); $dimohonOleh = trim((string) $dimohonOleh); if ($amaun <= 0) { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'Amaun tidak sah'], ]; } if ($dimohonOleh === '') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'dimohon_oleh diperlukan'], ]; } $tarikh = $this->todayDate(); $pending = $this->findPending($kodcaw); if ($pending) { return [ 'ok' => false, 'status' => 409, 'payload' => [ 'message' => 'Masih ada permohonan modal online PENDING untuk cawangan ini', 'request' => $pending, ], ]; } $modal = ModalOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('tarikh', $tarikh) ->first(); if ($modal && (int) ($modal->ditutup ?? 0) === 1) { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Modal online hari ini sudah ditutup (EOD). Tidak boleh mohon.', 'modal_online' => $modal, ], ]; } $jenis = $modal ? 'TOPUP' : 'HAD'; $request = RequestModalOnline::on('mysql7')->create([ 'kodcaw' => $kodcaw, 'tarikh' => $tarikh, 'amaun' => $amaun, 'dimohon_oleh' => $dimohonOleh, 'status' => 'PENDING', 'nota' => $nota, ]); return [ 'ok' => true, 'status' => 201, 'payload' => [ 'request' => $request, 'jenis' => $jenis, 'modal_online' => $modal ?: $this->missingSnapshot($kodcaw, $tarikh, $request), ], ]; } /** * List top-up requests. * For PC: pass kodcaw. For Operasi: omit kodcaw (all branches). * * @return array{ok:bool,status:int,payload:mixed} */ public function listTopUpRequests($kodcaw = null, $status = null, $tarikh = null) { $query = RequestModalOnline::on('mysql7')->newQuery(); if ($kodcaw) { $query->where('kodcaw', $kodcaw); } if ($status) { $query->where('status', $status); } if ($tarikh) { $query->where('tarikh', $tarikh); } $rows = $query->orderBy('id', 'desc')->get(); $cawMap = []; if (!$kodcaw) { foreach (Cawangan::on('mysql7')->get() as $caw) { $cawMap[$caw->kodcaw] = $caw->nama_cawangan; } } $data = []; foreach ($rows as $row) { $item = $row->toArray(); if (!$kodcaw) { $item['cawangan_nama'] = $cawMap[$row->kodcaw] ?? null; } $data[] = $item; } return [ 'ok' => true, 'status' => 200, 'payload' => [ 'bilangan' => count($data), 'data' => $data, ], ]; } /** * Operasi approve: first request of the day sets had; later requests add tambah. * Optional $amaunOverride lets operasi lulus a different amount than dimohon. * * @return array{ok:bool,status:int,payload:mixed} */ public function approveTopUp($requestId, $diluluskanOleh, $nota = null, $expectedKodcaw = null, $amaunOverride = null) { $diluluskanOleh = trim((string) $diluluskanOleh); if ($diluluskanOleh === '') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'diluluskan_oleh diperlukan'], ]; } $req = RequestModalOnline::on('mysql7')->where('id', $requestId)->first(); if (!$req) { return [ 'ok' => false, 'status' => 404, 'payload' => ['message' => 'Permohonan tidak dijumpai'], ]; } if ($expectedKodcaw && $req->kodcaw !== $expectedKodcaw) { return [ 'ok' => false, 'status' => 404, 'payload' => ['message' => 'Permohonan tidak dijumpai'], ]; } if ($req->status !== 'PENDING') { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Hanya permohonan PENDING boleh diluluskan', 'status' => $req->status, ], ]; } $kodcaw = $req->kodcaw; $resolved = $this->resolveMysql($kodcaw); if (!$resolved['ok']) { return $resolved; } $mysql = $resolved['mysql']; $amaunDimohon = round((float) $req->amaun, 2); $amaun = $amaunDimohon; if ($amaunOverride !== null && $amaunOverride !== '') { $amaun = round((float) $amaunOverride, 2); if ($amaun <= 0) { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'Amaun diluluskan tidak sah'], ]; } } $tarikh = $this->todayDate(); try { $applied = DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $tarikh, $amaun) { $modal = ModalOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('tarikh', $tarikh) ->lockForUpdate() ->first(); $jenis = 'TOPUP'; if (!$modal) { $modal = ModalOnline::on($mysql)->create([ 'tarikh' => $tarikh, 'kodcaw' => $kodcaw, 'had' => $amaun, 'digunakan' => 0.00, 'baki' => $amaun, 'tambah' => 0.00, 'kurang' => 0.00, 'ditutup' => 0, ]); $modal = ModalOnline::on($mysql) ->where('id', $modal->id) ->lockForUpdate() ->first(); $jenis = 'HAD'; } else { if ((int) ($modal->ditutup ?? 0) === 1) { throw new \RuntimeException('MODAL_ONLINE_CLOSED'); } $modal->tambah = round((float) $modal->tambah + $amaun, 2); $this->recalcBaki($modal); $modal->save(); } return ['modal' => $modal, 'jenis' => $jenis]; }); $modal = $applied['modal']; $jenis = $applied['jenis']; $req->status = 'APPROVED'; $req->diluluskan_oleh = $diluluskanOleh; $req->amaun = $amaun; $notes = []; if (abs($amaun - $amaunDimohon) >= 0.01) { $notes[] = 'Amaun dimohon RM ' . number_format($amaunDimohon, 2) . ', diluluskan RM ' . number_format($amaun, 2); } if ($nota !== null && trim((string) $nota) !== '') { $notes[] = trim((string) $nota); } if ($notes) { $existing = trim((string) $req->nota); $req->nota = $existing === '' ? implode(' | ', $notes) : $existing . ' | ' . implode(' | ', $notes); } $req->save(); return [ 'ok' => true, 'status' => 200, 'payload' => [ 'request' => $req->fresh(), 'jenis' => $jenis, 'modal_online' => $modal, ], ]; } catch (\Throwable $e) { if ($e->getMessage() === 'MODAL_ONLINE_CLOSED') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'Modal online hari ini sudah ditutup (EOD). Tidak boleh lulus permohonan.'], ]; } Log::error('approveTopUp failed: ' . $e->getMessage()); return [ 'ok' => false, 'status' => 500, 'payload' => ['message' => 'Gagal lulus permohonan modal online'], ]; } } /** * Operasi reject top-up request. * * @return array{ok:bool,status:int,payload:mixed} */ public function rejectTopUp($requestId, $diluluskanOleh, $nota = null, $expectedKodcaw = null) { $diluluskanOleh = trim((string) $diluluskanOleh); if ($diluluskanOleh === '') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'diluluskan_oleh diperlukan'], ]; } $req = RequestModalOnline::on('mysql7')->where('id', $requestId)->first(); if (!$req) { return [ 'ok' => false, 'status' => 404, 'payload' => ['message' => 'Permohonan tidak dijumpai'], ]; } if ($expectedKodcaw && $req->kodcaw !== $expectedKodcaw) { return [ 'ok' => false, 'status' => 404, 'payload' => ['message' => 'Permohonan tidak dijumpai'], ]; } if ($req->status !== 'PENDING') { return [ 'ok' => false, 'status' => 422, 'payload' => [ 'message' => 'Hanya permohonan PENDING boleh ditolak', 'status' => $req->status, ], ]; } $req->status = 'REJECTED'; $req->diluluskan_oleh = $diluluskanOleh; if ($nota !== null && trim((string) $nota) !== '') { $req->nota = trim( trim((string) $req->nota) === '' ? (string) $nota : $req->nota . ' | ' . $nota ); } $req->save(); return [ 'ok' => true, 'status' => 200, 'payload' => ['request' => $req], ]; } /** * EOD close for one branch: return unused baki via kurang, mark ditutup. * Also auto-rejects PENDING top-up requests for the branch. * * @return array{ok:bool,status:int,payload:mixed} */ public function close($kodcaw, $ditutupOleh) { $resolved = $this->resolveMysql($kodcaw); if (!$resolved['ok']) { return $resolved; } $mysql = $resolved['mysql']; $ditutupOleh = trim((string) $ditutupOleh); if ($ditutupOleh === '') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'ditutup_oleh diperlukan'], ]; } $tarikh = Carbon::now()->toDateString(); $now = Carbon::now(); try { $result = DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $tarikh, $ditutupOleh, $now) { $modal = ModalOnline::on($mysql) ->where('kodcaw', $kodcaw) ->where('tarikh', $tarikh) ->lockForUpdate() ->first(); if (!$modal) { return [ 'ok' => false, 'status' => 404, 'payload' => [ 'message' => 'Tiada modal online hari ini untuk ditutup. Teller belum mohon / PC belum lulus had.', 'missing' => true, ], ]; } if ((int) ($modal->ditutup ?? 0) === 1) { return [ 'ok' => false, 'status' => 409, 'payload' => [ 'message' => 'Modal online sudah ditutup untuk hari ini', 'modal_online' => $modal, ], ]; } $unused = round((float) $modal->baki, 2); $modal->kurang = round((float) $modal->kurang + $unused, 2); $modal->baki = round( (float) $modal->had + (float) $modal->tambah - (float) $modal->kurang - (float) $modal->digunakan, 2 ); $modal->ditutup = 1; $modal->ditutup_oleh = $ditutupOleh; $modal->ditutup_at = $now; $modal->save(); return [ 'ok' => true, 'status' => 200, 'payload' => [ 'modal_online' => $modal, 'dikembalikan' => $unused, ], ]; }); if (!$result['ok']) { return $result; } $pendingRejected = RequestModalOnline::on('mysql7') ->where('kodcaw', $kodcaw) ->where('status', 'PENDING') ->get(); foreach ($pendingRejected as $req) { $req->status = 'REJECTED'; $req->diluluskan_oleh = $ditutupOleh; $tag = 'Auto reject: EOD close'; $req->nota = trim((string) $req->nota) === '' ? $tag : trim($req->nota) . ' | ' . $tag; $req->save(); } $result['payload']['pending_topup_ditolak'] = $pendingRejected->count(); return $result; } catch (\Throwable $e) { Log::error('modal online close failed: ' . $e->getMessage()); return [ 'ok' => false, 'status' => 500, 'payload' => ['message' => 'Gagal tutup modal online'], ]; } } /** * Operasi: list today's modal_online across branches. * * @return array{ok:bool,status:int,payload:mixed} */ public function listToday($zone = 'all', $tarikh = null) { $tarikh = $tarikh ?: Carbon::now()->toDateString(); if ($zone == 'all') { $cawanganAll = Cawangan::on('mysql7')->get(); } else { $cawanganAll = Cawangan::on('mysql7')->where('zone', $zone)->get(); } $rows = []; foreach ($cawanganAll as $cawangan) { try { $modal = ModalOnline::on($cawangan['mysql']) ->where('kodcaw', $cawangan['kodcaw']) ->where('tarikh', $tarikh) ->first(); $row = $modal ? $modal->toArray() : [ 'tarikh' => $tarikh, 'kodcaw' => $cawangan['kodcaw'], 'had' => 0, 'digunakan' => 0, 'baki' => 0, 'tambah' => 0, 'kurang' => 0, 'ditutup' => 0, 'missing' => true, ]; $row['cawangan_nama'] = $cawangan->nama_cawangan; $row['missing'] = empty($modal); $rows[] = $row; } catch (\Throwable $e) { Log::warning('listToday modal_online skip ' . $cawangan['kodcaw'] . ': ' . $e->getMessage()); } } usort($rows, function ($a, $b) { return strcmp((string) ($a['kodcaw'] ?? ''), (string) ($b['kodcaw'] ?? '')); }); $open = 0; $closed = 0; $missing = 0; foreach ($rows as $row) { if (!empty($row['missing'])) { $missing++; } elseif ((int) ($row['ditutup'] ?? 0) === 1) { $closed++; } else { $open++; } } return [ 'ok' => true, 'status' => 200, 'payload' => [ 'tarikh' => $tarikh, 'bilangan' => count($rows), 'open' => $open, 'closed' => $closed, 'missing' => $missing, 'data' => $rows, ], ]; } /** * Operasi bulk EOD close for open branches today. * * @return array{ok:bool,status:int,payload:mixed} */ public function closeAll($ditutupOleh, $zone = 'all') { $ditutupOleh = trim((string) $ditutupOleh); if ($ditutupOleh === '') { return [ 'ok' => false, 'status' => 422, 'payload' => ['message' => 'ditutup_oleh diperlukan'], ]; } $list = $this->listToday($zone); $closed = 0; $skipped = 0; $failed = []; foreach ($list['payload']['data'] as $row) { if (!empty($row['missing']) || (int) ($row['ditutup'] ?? 0) === 1) { $skipped++; continue; } $res = $this->close($row['kodcaw'], $ditutupOleh); if ($res['ok']) { $closed++; } else { $failed[] = [ 'kodcaw' => $row['kodcaw'], 'message' => $res['payload']['message'] ?? 'gagal', ]; } } return [ 'ok' => true, 'status' => 200, 'payload' => [ 'closed' => $closed, 'skipped' => $skipped, 'failed' => $failed, ], ]; } }