diff --git a/application/controllers/mockup/fo/ibl_registration/Order.php b/application/controllers/mockup/fo/ibl_registration/Order.php index b8c4e573..763bc345 100644 --- a/application/controllers/mockup/fo/ibl_registration/Order.php +++ b/application/controllers/mockup/fo/ibl_registration/Order.php @@ -455,17 +455,6 @@ class Order extends MY_Controller exit; } - $fn_generate_location = $this->generate_location($header_id, $userid); - if (!$fn_generate_location['status']) { - $this->db_smartone->trans_rollback(); - $message = $fn_generate_location['message'] ?? 'Terjadi kesalahan saat membuat lokasi'; - if ($internal) - return ['status' => 'ERR', 'message' => $message]; - $this->sys_error($message); - exit; - } - - $fn_generate_req = $this->generate_req($header_id, $prm['req'], $userid); if (!$fn_generate_req['status']) { $this->db_smartone->trans_rollback(); @@ -611,6 +600,20 @@ class Order extends MY_Controller $this->db_smartone->trans_commit(); + $fn_generate_location = $this->generate_location($header_id, $userid); + $location_warning = null; + if ($fn_generate_location['has_failure']) { + $failed_parts = array_map(function ($f) { + return $f['name'] . ' (ID:' . $f['id'] . ')'; + }, $fn_generate_location['failed']); + $failed_str = implode(', ', $failed_parts); + $location_warning = [ + 'has_error' => true, + 'message' => "Order tersimpan sebagian. Gagal membuat lokasi: {$failed_str} belum punya mapping m_location aktif. Tindakan: tambahkan mapping lokasi aktif untuk station tersebut lalu retry generate location. OrderID: {$header_id}.", + 'failed_stations' => $fn_generate_location['failed'], + ]; + } + $sql = "SELECT * FROM `s_menu` WHERE `S_MenuName` = 'Pre-Register' AND `S_MenuIsActive` = 'Y' LIMIT 1"; $query_menu = $this->db_smartone->query($sql); if (!$query_menu) { @@ -783,12 +786,13 @@ class Order extends MY_Controller 'url_preregister' => $url_preregister, 'data' => array('id' => $header_id, 'number' => $lab_number), 'order_delivery' => isset($prm['delivery']) ? $prm['delivery'] : $order_delivery, - 'order_detail' => $order_detail, - 'order_header' => $order_header, - 'inform_consent' => isset($order_header['inform_consent']) ? $order_header['inform_consent'] : [], - 'order_promise' => $order_promise, - 'order_details' => $order_details, - 'report_url' => $report_url + 'order_detail' => $order_detail, + 'order_header' => $order_header, + 'inform_consent' => isset($order_header['inform_consent']) ? $order_header['inform_consent'] : [], + 'order_promise' => $order_promise, + 'order_details' => $order_details, + 'report_url' => $report_url, + 'location_warning' => $location_warning, ]; if ($internal) @@ -879,10 +883,10 @@ class Order extends MY_Controller GROUP BY Group_ResultID, Group_ResultName HAVING Group_ResultID IS NOT NULL AND Group_Concat(T_OrderDetailID) IS NOT NULL UNION - SELECT DISTINCT Group_ResultID as Group_ResultID, T_TestName as Group_ResultName, Group_Concat(T_OrderDetailID) as T_OrderDetailIDs, T_TestID as T_TestID - FROM t_orderdetail - JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsResult = 'Y' AND - T_TestIsActive = 'Y' + SELECT DISTINCT Group_ResultID as Group_ResultID, T_TestName as Group_ResultName, Group_Concat(T_OrderDetailID) as T_OrderDetailIDs, T_TestID as T_TestID + FROM t_orderdetail + JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsResult = 'Y' AND + T_TestIsActive = 'Y' JOIN group_resultdetail ON Group_ResultDetailT_TestID = T_TestID AND Group_ResultDetailIsActive = 'Y' JOIN group_result ON Group_ResultDetailGroup_ResultID = Group_ResultID AND @@ -988,8 +992,8 @@ class Order extends MY_Controller return $return; } - function get_order_header($order_id) - { + function get_order_header($order_id) + { $return = [ "status" => true, "message" => "", @@ -1056,97 +1060,97 @@ class Order extends MY_Controller } - $data = $query->row_array(); - if (is_array($data) && count($data) > 0) { - $data['inform_consent'] = $this->get_inform_consent_by_order($order_id); - } - $return['data'] = $data; - return $return; - } - - function get_inform_consent_by_order($order_id) - { - $fallback = $this->get_inform_consent_template('umum'); - $fallback['company_type_group_name'] = ''; - $fallback['company_type_group_id'] = 0; - - $sql_group = "SELECT - IFNULL(c.M_CompanyM_CompanyGroupTypeID, 0) as company_type_group_id, - IFNULL(g.M_CompanyTypeGroupName, '') as company_type_group_name - FROM t_orderheader h - JOIN m_company c ON h.T_OrderHeaderM_CompanyID = c.M_CompanyID - LEFT JOIN m_companytypegroup g ON c.M_CompanyM_CompanyGroupTypeID = g.M_CompanyTypeGroupID - WHERE h.T_OrderHeaderID = ? - LIMIT 1"; - $query_group = $this->db_smartone->query($sql_group, [$order_id]); - if (!$query_group) { - return $fallback; - } - - $dt_group = $query_group->row_array(); - if (!$dt_group) { - return $fallback; - } - - $group_type_id = intval($dt_group['company_type_group_id']); - $template_type = ($group_type_id === 8) ? 'cpmi' : 'umum'; - - $sql_template = "SELECT - M_InformConsentType as type, - M_InformConsentTitle as title, - M_InformConsentContent as content - FROM m_informconsent - WHERE M_InformConsentType = ? - AND M_InformConsentIsActive = 'Y' - LIMIT 1"; - $query_template = $this->db_smartone->query($sql_template, [$template_type]); - if (!$query_template) { - $fallback = $this->get_inform_consent_template($template_type); - $fallback['company_type_group_name'] = $dt_group['company_type_group_name']; - $fallback['company_type_group_id'] = intval($dt_group['company_type_group_id']); - return $fallback; - } - - $dt_template = $query_template->row_array(); - if (!$dt_template) { - $fallback = $this->get_inform_consent_template($template_type); - $fallback['company_type_group_name'] = $dt_group['company_type_group_name']; - $fallback['company_type_group_id'] = intval($dt_group['company_type_group_id']); - return $fallback; - } - - $dt_template['company_type_group_name'] = $dt_group['company_type_group_name']; - $dt_template['company_type_group_id'] = intval($dt_group['company_type_group_id']); - return $dt_template; - } - - function get_inform_consent_template($template_type) - { - $fallback = [ - 'type' => $template_type, - 'title' => '', - 'content' => '' - ]; - - $sql_template = "SELECT - M_InformConsentType as type, - M_InformConsentTitle as title, - M_InformConsentContent as content - FROM m_informconsent - WHERE M_InformConsentType = ? - AND M_InformConsentIsActive = 'Y' - LIMIT 1"; - $query_template = $this->db_smartone->query($sql_template, [$template_type]); - if (!$query_template) { - return $fallback; - } - - $dt_template = $query_template->row_array(); - if (!$dt_template) { - return $fallback; - } - return $dt_template; - } + $data = $query->row_array(); + if (is_array($data) && count($data) > 0) { + $data['inform_consent'] = $this->get_inform_consent_by_order($order_id); + } + $return['data'] = $data; + return $return; + } + + function get_inform_consent_by_order($order_id) + { + $fallback = $this->get_inform_consent_template('umum'); + $fallback['company_type_group_name'] = ''; + $fallback['company_type_group_id'] = 0; + + $sql_group = "SELECT + IFNULL(c.M_CompanyM_CompanyGroupTypeID, 0) as company_type_group_id, + IFNULL(g.M_CompanyTypeGroupName, '') as company_type_group_name + FROM t_orderheader h + JOIN m_company c ON h.T_OrderHeaderM_CompanyID = c.M_CompanyID + LEFT JOIN m_companytypegroup g ON c.M_CompanyM_CompanyGroupTypeID = g.M_CompanyTypeGroupID + WHERE h.T_OrderHeaderID = ? + LIMIT 1"; + $query_group = $this->db_smartone->query($sql_group, [$order_id]); + if (!$query_group) { + return $fallback; + } + + $dt_group = $query_group->row_array(); + if (!$dt_group) { + return $fallback; + } + + $group_type_id = intval($dt_group['company_type_group_id']); + $template_type = ($group_type_id === 8) ? 'cpmi' : 'umum'; + + $sql_template = "SELECT + M_InformConsentType as type, + M_InformConsentTitle as title, + M_InformConsentContent as content + FROM m_informconsent + WHERE M_InformConsentType = ? + AND M_InformConsentIsActive = 'Y' + LIMIT 1"; + $query_template = $this->db_smartone->query($sql_template, [$template_type]); + if (!$query_template) { + $fallback = $this->get_inform_consent_template($template_type); + $fallback['company_type_group_name'] = $dt_group['company_type_group_name']; + $fallback['company_type_group_id'] = intval($dt_group['company_type_group_id']); + return $fallback; + } + + $dt_template = $query_template->row_array(); + if (!$dt_template) { + $fallback = $this->get_inform_consent_template($template_type); + $fallback['company_type_group_name'] = $dt_group['company_type_group_name']; + $fallback['company_type_group_id'] = intval($dt_group['company_type_group_id']); + return $fallback; + } + + $dt_template['company_type_group_name'] = $dt_group['company_type_group_name']; + $dt_template['company_type_group_id'] = intval($dt_group['company_type_group_id']); + return $dt_template; + } + + function get_inform_consent_template($template_type) + { + $fallback = [ + 'type' => $template_type, + 'title' => '', + 'content' => '' + ]; + + $sql_template = "SELECT + M_InformConsentType as type, + M_InformConsentTitle as title, + M_InformConsentContent as content + FROM m_informconsent + WHERE M_InformConsentType = ? + AND M_InformConsentIsActive = 'Y' + LIMIT 1"; + $query_template = $this->db_smartone->query($sql_template, [$template_type]); + if (!$query_template) { + return $fallback; + } + + $dt_template = $query_template->row_array(); + if (!$dt_template) { + return $fallback; + } + return $dt_template; + } @@ -2194,84 +2198,100 @@ class Order extends MY_Controller function generate_location($header_id, $userid) { - $result = [ - 'status' => true, - 'message' => 'Success', - 'error_type' => '', - 'error_detail' => [] - ]; - - - $sql = "SELECT T_SampleStationID,T_SampleStationName + $sql = "SELECT T_SampleStationID, T_SampleStationName FROM t_orderdetail JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsActive = 'Y' JOIN t_sampletype ON T_TestT_SampleTypeID = T_SampleTypeID JOIN t_bahan ON T_SampleTypeT_BahanID = T_BahanID JOIN t_samplestation ON T_BahanT_SampleStationID = T_SampleStationID - WHERE - T_OrderDetailT_OrderHeaderID = ? - GROUP BY T_SampleStationID "; + WHERE T_OrderDetailT_OrderHeaderID = ? + GROUP BY T_SampleStationID"; $qry = $this->db_smartone->query($sql, $header_id); - $data_samples = $qry->result_array(); - foreach ($data_samples as $k => $v) { - $sample_id = $v['T_SampleStationID']; - $sql = "SELECT M_LocationID as loc_id + if (!$qry) { + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_STATIONS_QUERY_ERROR', 'order/generate_location'], + ['order_id' => $header_id] + ); + return [ + 'has_failure' => true, + 'succeeded' => [], + 'failed' => [['id' => 0, 'name' => 'unknown', 'reason' => 'STATIONS_QUERY_ERROR']], + ]; + } + + $stations = $qry->result_array(); + return $this->_do_generate_location_for_stations($stations, $header_id, $userid); + } + + private function _do_generate_location_for_stations(array $stations, $header_id, $userid) + { + $succeeded = []; + $failed = []; + + foreach ($stations as $v) { + $sample_id = $v['T_SampleStationID']; + $sample_name = $v['T_SampleStationName']; + + $sql = "SELECT M_LocationID as loc_id FROM m_location - WHERE M_LocationT_SampleStationID = ? AND M_LocationIsActive = 'Y' + WHERE M_LocationT_SampleStationID = ? AND M_LocationIsActive = 'Y' ORDER BY M_LocationPriority DESC, M_LocationID ASC LIMIT 1"; $qry = $this->db_smartone->query($sql, $sample_id); + if (!$qry) { - $result['status'] = false; - $result['message'] = 'Terjadi kesalahan saat mengambil data lokasi ' . $v['T_SampleStationName']; - $result['error_type'] = 'LOCATION_QUERY_ERROR'; - $result['error_detail'] = [ - 'sql_error' => $this->db_smartone->error(), - 'last_query' => $this->db_smartone->last_query() - ]; - $prm_log = ['SELECT_LOCATION', 'order/generate_location']; - $xsql = $this->db_smartone->last_query(); - $log_error = $this->insert_log_error($xsql, $prm_log); - //$this->db_smartone->trans_rollback(); - return $result; + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'QUERY_ERROR']; + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_QUERY_ERROR', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; } - $loc_id = $qry->row()->loc_id; + + $row = $qry->row(); + if (!$row) { + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'NO_MAPPING']; + $this->insert_log_error( + 'no active m_location for station ' . $sample_id, + ['LOCATION_NO_MAPPING', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; + } + + $loc_id = $row->loc_id; $sql = "INSERT INTO t_order_location( - T_OrderLocationT_OrderHeaderID, + T_OrderLocationT_OrderHeaderID, T_OrderLocationM_LocationID, T_OrderLocationT_SampleStationID, - T_OrderLocationCreated, - T_OrderLocationLastUpdated, + T_OrderLocationCreated, + T_OrderLocationLastUpdated, T_OrderLocationUserID) VALUES (?,?,?,NOW(),NOW(),?)"; - $prm_orderlocation = [ - $header_id, - $loc_id, - $sample_id, - $userid - ]; - $qry = $this->db_smartone->query($sql, $prm_orderlocation); + $qry = $this->db_smartone->query($sql, [$header_id, $loc_id, $sample_id, $userid]); + if (!$qry) { - $result['status'] = false; - $result['message'] = 'Terjadi kesalahan saat menyimpan data lokasi'; - $result['error_type'] = 'ORDER_LOCATION_INSERT_ERROR'; - $result['error_detail'] = [ - 'sql_error' => $this->db_smartone->error(), - 'last_query' => $this->db_smartone->last_query() - ]; - $prm_log = ['INSERT_T_ORDERLOCATION', 'order/generate_location']; - $xsql = $this->db_smartone->last_query(); - $log_error = $this->insert_log_error($xsql, $prm_log); - //$this->db_smartone->trans_rollback(); - return $result; + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'INSERT_ERROR']; + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_INSERT_ERROR', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; } - //file_get_contents("http://127.0.0.1:9088/broadcast/sm.new." . $sample_id . "." . $mcuid . "." . $branch_id); + $succeeded[] = ['id' => $sample_id, 'name' => $sample_name]; } - return $result; + return [ + 'has_failure' => count($failed) > 0, + 'succeeded' => $succeeded, + 'failed' => $failed, + ]; } function generate_req($header_id, $req, $userid) @@ -4463,4 +4483,75 @@ GROUP BY T_SampleStationID "; exit; } -} + function retry_location() + { + if (!$this->isLogin) { + $this->sys_error("Invalid Token"); + exit; + } + + $prm = $this->sys_input; + $header_id = isset($prm['order_id']) ? (int) $prm['order_id'] : 0; + $userid = $this->sys_user["M_UserID"]; + + if (!$header_id) { + $this->sys_error("order_id tidak boleh kosong"); + exit; + } + + // Ambil semua station terkait order + $sql = "SELECT T_SampleStationID, T_SampleStationName + FROM t_orderdetail + JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsActive = 'Y' + JOIN t_sampletype ON T_TestT_SampleTypeID = T_SampleTypeID + JOIN t_bahan ON T_SampleTypeT_BahanID = T_BahanID + JOIN t_samplestation ON T_BahanT_SampleStationID = T_SampleStationID + WHERE T_OrderDetailT_OrderHeaderID = ? + GROUP BY T_SampleStationID"; + $qry = $this->db_smartone->query($sql, $header_id); + if (!$qry) { + $this->sys_error("Gagal mengambil data station untuk order " . $header_id); + exit; + } + $all_stations = $qry->result_array(); + + // Ambil station yang sudah punya lokasi + $sql = "SELECT T_OrderLocationT_SampleStationID as station_id + FROM t_order_location + WHERE T_OrderLocationT_OrderHeaderID = ?"; + $qry = $this->db_smartone->query($sql, $header_id); + if (!$qry) { + $this->sys_error("Gagal mengambil data lokasi existing untuk order " . $header_id); + exit; + } + $existing_ids = array_column($qry->result_array(), 'station_id'); + + // Hanya proses station yang belum punya lokasi + $pending = array_values(array_filter($all_stations, function ($s) use ($existing_ids) { + return !in_array($s['T_SampleStationID'], $existing_ids); + })); + + if (empty($pending)) { + $this->sys_ok([ + 'order_id' => $header_id, + 'succeeded' => [], + 'failed' => [], + 'message' => 'Semua lokasi sudah terbentuk untuk order ini.', + ]); + exit; + } + + $result = $this->_do_generate_location_for_stations($pending, $header_id, $userid); + $succeeded_count = count($result['succeeded']); + $failed_count = count($result['failed']); + + $this->sys_ok([ + 'order_id' => $header_id, + 'succeeded' => $result['succeeded'], + 'failed' => $result['failed'], + 'message' => "Retry selesai. {$succeeded_count} berhasil, {$failed_count} gagal.", + ]); + exit; + } + +} diff --git a/docs/superpowers/plans/2026-05-19-ibl-registration-order-location-resilience.md b/docs/superpowers/plans/2026-05-19-ibl-registration-order-location-resilience.md new file mode 100644 index 00000000..d8a9ce0a --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-ibl-registration-order-location-resilience.md @@ -0,0 +1,563 @@ +# IBL Registration Order — Location Generation Resilience Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Order header/detail/sample tetap tersimpan walau `generate_location` gagal; lokasi di-generate per-station (tidak abort di kegagalan pertama); error spesifik & actionable; endpoint retry tersedia. + +**Architecture:** Pindahkan `generate_location()` ke luar `trans_begin/commit` agar order tidak ikut rollback saat lokasi gagal. Extract core insert-location logic ke private method `_do_generate_location_for_stations()` yang dipakai bersama oleh `generate_location()` dan endpoint `retry_location()`. Tambahkan field opsional `location_warning` di response sukses. + +**Tech Stack:** PHP 7.x, CodeIgniter 3, MySQL. Tidak ada test runner — verifikasi manual via curl/Postman sesuai skenario UAT. + +--- + +## File yang Diubah + +| File | Tipe | Keterangan | +|---|---|---| +| `application/controllers/mockup/fo/ibl_registration/Order.php` | Modify | Satu-satunya file yang disentuh | + +Semua perubahan ada di file ini: +- Fungsi `save()` — ubah urutan pemanggilan, tambah `location_warning` di response +- Fungsi `generate_location()` — delegasi ke private method baru +- **Baru** private `_do_generate_location_for_stations()` — logika insert per-station +- **Baru** public `retry_location()` — endpoint retry + +--- + +## Task 1: Extract `_do_generate_location_for_stations()` + +Ini fondasi semua perubahan. Semua logika insert location per-station dipindah ke sini. + +**Files:** +- Modify: `application/controllers/mockup/fo/ibl_registration/Order.php` (tambah setelah line 2275) + +- [ ] **Step 1.1 — Verifikasi titik sisip** + + Buka file, konfirmasi line 2275 adalah baris `}` penutup `generate_location()` dan line 2277 adalah baris pertama `generate_req()`. + + ```bash + sed -n '2273,2280p' /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Expected output: + ``` + return $result; + } + + function generate_req($header_id, $req, $userid) + ``` + +- [ ] **Step 1.2 — Sisipkan private method baru setelah baris penutup `generate_location()`** + + Sisipkan blok berikut tepat setelah `}` penutup `generate_location()` (antara line 2275 dan 2277): + + ```php + private function _do_generate_location_for_stations(array $stations, $header_id, $userid) + { + $succeeded = []; + $failed = []; + + foreach ($stations as $v) { + $sample_id = $v['T_SampleStationID']; + $sample_name = $v['T_SampleStationName']; + + $sql = "SELECT M_LocationID as loc_id + FROM m_location + WHERE M_LocationT_SampleStationID = ? AND M_LocationIsActive = 'Y' + ORDER BY M_LocationPriority DESC, M_LocationID ASC + LIMIT 1"; + $qry = $this->db_smartone->query($sql, $sample_id); + + if (!$qry) { + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'QUERY_ERROR']; + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_QUERY_ERROR', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; + } + + $row = $qry->row(); + if (!$row) { + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'NO_MAPPING']; + $this->insert_log_error( + 'no active m_location for station ' . $sample_id, + ['LOCATION_NO_MAPPING', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; + } + + $loc_id = $row->loc_id; + + $sql = "INSERT INTO t_order_location( + T_OrderLocationT_OrderHeaderID, + T_OrderLocationM_LocationID, + T_OrderLocationT_SampleStationID, + T_OrderLocationCreated, + T_OrderLocationLastUpdated, + T_OrderLocationUserID) + VALUES (?,?,?,NOW(),NOW(),?)"; + $qry = $this->db_smartone->query($sql, [$header_id, $loc_id, $sample_id, $userid]); + + if (!$qry) { + $failed[] = ['id' => $sample_id, 'name' => $sample_name, 'reason' => 'INSERT_ERROR']; + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_INSERT_ERROR', 'order/generate_location'], + ['order_id' => $header_id, 'station_id' => $sample_id, 'station_name' => $sample_name] + ); + continue; + } + + $succeeded[] = ['id' => $sample_id, 'name' => $sample_name]; + } + + return [ + 'has_failure' => count($failed) > 0, + 'succeeded' => $succeeded, + 'failed' => $failed, + ]; + } + ``` + +- [ ] **Step 1.3 — Verifikasi method tersisip tanpa syntax error** + + ```bash + php -l /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Expected: `No syntax errors detected in ...Order.php` + +--- + +## Task 2: Refactor `generate_location()` — delegasi ke private method + +Ganti seluruh isi `generate_location()` agar hanya bertugas query stations lalu memanggil `_do_generate_location_for_stations()`. + +**Files:** +- Modify: `Order.php` line 2195–2275 + +- [ ] **Step 2.1 — Catat baris pertama dan terakhir `generate_location()`** + + ```bash + sed -n '2195,2275p' /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Pastikan range 2195–2275 masih sesuai (bisa bergeser setelah Task 1). + +- [ ] **Step 2.2 — Ganti isi `generate_location()`** + + Ganti seluruh function body `generate_location` (dari baris `function generate_location` sampai `}` penutupnya) dengan: + + ```php + function generate_location($header_id, $userid) + { + $sql = "SELECT T_SampleStationID, T_SampleStationName + FROM t_orderdetail + JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsActive = 'Y' + JOIN t_sampletype ON T_TestT_SampleTypeID = T_SampleTypeID + JOIN t_bahan ON T_SampleTypeT_BahanID = T_BahanID + JOIN t_samplestation ON T_BahanT_SampleStationID = T_SampleStationID + WHERE T_OrderDetailT_OrderHeaderID = ? + GROUP BY T_SampleStationID"; + $qry = $this->db_smartone->query($sql, $header_id); + + if (!$qry) { + $this->insert_log_error( + $this->db_smartone->last_query(), + ['LOCATION_STATIONS_QUERY_ERROR', 'order/generate_location'], + ['order_id' => $header_id] + ); + return [ + 'has_failure' => true, + 'succeeded' => [], + 'failed' => [['id' => 0, 'name' => 'unknown', 'reason' => 'STATIONS_QUERY_ERROR']], + ]; + } + + $stations = $qry->result_array(); + return $this->_do_generate_location_for_stations($stations, $header_id, $userid); + } + ``` + +- [ ] **Step 2.3 — Syntax check** + + ```bash + php -l /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Expected: `No syntax errors detected` + +--- + +## Task 3: Restructure `save()` — pindah `generate_location` ke luar transaksi + +**Files:** +- Modify: `Order.php` — dua titik di dalam function `save()` + +- [ ] **Step 3.1 — Hapus blok `generate_location` dari dalam transaksi (line 458–466)** + + Hapus 9 baris berikut (termasuk baris kosong sesudahnya): + + ```php + $fn_generate_location = $this->generate_location($header_id, $userid); + if (!$fn_generate_location['status']) { + $this->db_smartone->trans_rollback(); + $message = $fn_generate_location['message'] ?? 'Terjadi kesalahan saat membuat lokasi'; + if ($internal) + return ['status' => 'ERR', 'message' => $message]; + $this->sys_error($message); + exit; + } + ``` + +- [ ] **Step 3.2 — Tambahkan blok `generate_location` setelah `trans_commit()` (line 612)** + + Setelah baris `$this->db_smartone->trans_commit();`, sisipkan: + + ```php + $fn_generate_location = $this->generate_location($header_id, $userid); + $location_warning = null; + if ($fn_generate_location['has_failure']) { + $failed_parts = array_map(function ($f) { + return $f['name'] . ' (ID:' . $f['id'] . ')'; + }, $fn_generate_location['failed']); + $failed_str = implode(', ', $failed_parts); + $location_warning = [ + 'has_error' => true, + 'message' => "Order tersimpan sebagian. Gagal membuat lokasi: {$failed_str} belum punya mapping m_location aktif. Tindakan: tambahkan mapping lokasi aktif untuk station tersebut lalu retry generate location. OrderID: {$header_id}.", + 'failed_stations' => $fn_generate_location['failed'], + ]; + } + ``` + +- [ ] **Step 3.3 — Tambahkan `location_warning` ke array `$dt_order`** + + Di array `$dt_order` (sekitar line 772), tambahkan satu entry di akhir (sebelum `]`): + + ```php + 'report_url' => $report_url, + 'location_warning' => $location_warning, + ``` + + Sehingga `$dt_order` berakhir: + ```php + $dt_order = [ + 'status' => 'OK', + 'order_id' => $x_data_array['T_OrderHeaderID'], + // ... field lainnya tidak berubah ... + 'report_url' => $report_url, + 'location_warning' => $location_warning, + ]; + ``` + +- [ ] **Step 3.4 — Syntax check** + + ```bash + php -l /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Expected: `No syntax errors detected` + +--- + +## Task 4: Tambah endpoint `retry_location()` + +**Files:** +- Modify: `Order.php` — tambah method baru sebelum penutup class (cari baris `}` paling akhir file) + +- [ ] **Step 4.1 — Temukan baris penutup class** + + ```bash + tail -5 /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Catat nomor baris `}` penutup class. + +- [ ] **Step 4.2 — Sisipkan method `retry_location()` sebelum `}` penutup class** + + ```php + function retry_location() + { + if (!$this->isLogin) { + $this->sys_error("Invalid Token"); + exit; + } + + $prm = $this->sys_input; + $header_id = isset($prm['order_id']) ? (int) $prm['order_id'] : 0; + $userid = $this->sys_user["M_UserID"]; + + if (!$header_id) { + $this->sys_error("order_id tidak boleh kosong"); + exit; + } + + // Ambil semua station terkait order + $sql = "SELECT T_SampleStationID, T_SampleStationName + FROM t_orderdetail + JOIN t_test ON T_OrderDetailT_TestID = T_TestID AND T_TestIsActive = 'Y' + JOIN t_sampletype ON T_TestT_SampleTypeID = T_SampleTypeID + JOIN t_bahan ON T_SampleTypeT_BahanID = T_BahanID + JOIN t_samplestation ON T_BahanT_SampleStationID = T_SampleStationID + WHERE T_OrderDetailT_OrderHeaderID = ? + GROUP BY T_SampleStationID"; + $qry = $this->db_smartone->query($sql, $header_id); + if (!$qry) { + $this->sys_error("Gagal mengambil data station untuk order " . $header_id); + exit; + } + $all_stations = $qry->result_array(); + + // Ambil station yang sudah punya lokasi + $sql = "SELECT T_OrderLocationT_SampleStationID as station_id + FROM t_order_location + WHERE T_OrderLocationT_OrderHeaderID = ?"; + $qry = $this->db_smartone->query($sql, $header_id); + if (!$qry) { + $this->sys_error("Gagal mengambil data lokasi existing untuk order " . $header_id); + exit; + } + $existing_ids = array_column($qry->result_array(), 'station_id'); + + // Hanya proses station yang belum punya lokasi + $pending = array_values(array_filter($all_stations, function ($s) use ($existing_ids) { + return !in_array($s['T_SampleStationID'], $existing_ids); + })); + + if (empty($pending)) { + $this->sys_ok([ + 'order_id' => $header_id, + 'succeeded' => [], + 'failed' => [], + 'message' => 'Semua lokasi sudah terbentuk untuk order ini.', + ]); + exit; + } + + $result = $this->_do_generate_location_for_stations($pending, $header_id, $userid); + $succeeded_count = count($result['succeeded']); + $failed_count = count($result['failed']); + + $this->sys_ok([ + 'order_id' => $header_id, + 'succeeded' => $result['succeeded'], + 'failed' => $result['failed'], + 'message' => "Retry selesai. {$succeeded_count} berhasil, {$failed_count} gagal.", + ]); + exit; + } + ``` + +- [ ] **Step 4.3 — Syntax check** + + ```bash + php -l /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/application/controllers/mockup/fo/ibl_registration/Order.php + ``` + + Expected: `No syntax errors detected` + +- [ ] **Step 4.4 — Commit semua perubahan** + + ```bash + cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab + git add application/controllers/mockup/fo/ibl_registration/Order.php + git commit -m "feat(ibl-registration): order save resilient terhadap kegagalan generate_location + + - generate_location dipindah ke luar trans_begin/commit agar order tidak rollback + - _do_generate_location_for_stations() diextract, iterasi semua station tanpa abort + - deteksi NO_MAPPING (loc_id null) sebelum INSERT + - location_warning ditambahkan ke response sukses (opsional untuk FE) + - endpoint retry_location untuk generate ulang lokasi yang belum terbentuk + - tiap kegagalan station dicatat ke error_log_order" + ``` + +--- + +## Task 5: UAT Manual — 3 Skenario + +Untuk setiap skenario, ganti ``, ``, dan payload sesuai environment dev. + +### Skenario 1 — Happy Path (semua mapping lengkap) + +- [ ] **Step 5.1 — Pastikan semua sample station pada order yang akan dites punya entry aktif di `m_location`** + + ```sql + -- Jalankan di DB dev, ganti $order_detail_test sesuai test data + SELECT T_SampleStationID, T_SampleStationName, + M_LocationID, M_LocationIsActive + FROM t_samplestation + LEFT JOIN m_location ON M_LocationT_SampleStationID = T_SampleStationID + AND M_LocationIsActive = 'Y' + WHERE T_SampleStationID IN (/* station ID yang akan dipakai test */); + ``` + + Expected: setiap station punya minimal 1 baris dengan `M_LocationIsActive = 'Y'`. + +- [ ] **Step 5.2 — Hit endpoint save** + + ```bash + curl -s -X POST https:///one-api-lab/mockup/fo/ibl_registration/order/save \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '' | python3 -m json.tool + ``` + + Expected response: + ```json + { + "status": "OK", + "data": { + "status": "OK", + "order_id": , + "noreg": "", + "location_warning": null, + ... + } + } + ``` + +- [ ] **Step 5.3 — Verifikasi `t_order_location` terisi** + + ```sql + SELECT * FROM t_order_location WHERE T_OrderLocationT_OrderHeaderID = ; + ``` + + Expected: ada row untuk setiap sample station order tersebut. + +--- + +### Skenario 2 — Partial Failure (satu station tanpa mapping) + +- [ ] **Step 5.4 — Nonaktifkan mapping salah satu station (DEV only)** + + ```sql + -- Catat station ID yang akan dinonaktifkan + UPDATE m_location + SET M_LocationIsActive = 'N' + WHERE M_LocationT_SampleStationID = ; + ``` + +- [ ] **Step 5.5 — Hit endpoint save dengan order yang mencakup station tersebut** + + ```bash + curl -s -X POST https:///one-api-lab/mockup/fo/ibl_registration/order/save \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '' | python3 -m json.tool + ``` + + Expected response: + ```json + { + "status": "OK", + "data": { + "status": "OK", + "order_id": , + "location_warning": { + "has_error": true, + "message": "Order tersimpan sebagian. Gagal membuat lokasi: (ID:) belum punya mapping m_location aktif. Tindakan: ...", + "failed_stations": [ + {"id": , "name": "", "reason": "NO_MAPPING"} + ] + }, + ... + } + } + ``` + +- [ ] **Step 5.6 — Verifikasi order tersimpan di DB** + + ```sql + SELECT T_OrderHeaderID, T_OrderHeaderLabNumber + FROM t_orderheader WHERE T_OrderHeaderID = ; + ``` + + Expected: ada 1 row — order tersimpan meski lokasi gagal. + +- [ ] **Step 5.7 — Verifikasi station lain tetap punya lokasi** + + ```sql + SELECT T_OrderLocationT_SampleStationID + FROM t_order_location + WHERE T_OrderLocationT_OrderHeaderID = ; + ``` + + Expected: ada row untuk station yang mapping-nya aktif, tidak ada row untuk station yang dinonaktifkan. + +- [ ] **Step 5.8 — Verifikasi error tercatat di `error_log_order`** + + ```sql + SELECT * FROM error_log_order + WHERE ErrorLogOrderCode = 'LOCATION_NO_MAPPING' + ORDER BY ErrorLogOrderCreated DESC LIMIT 5; + ``` + + Expected: ada entry baru dengan `ErrorLogOrderData` berisi order_id dan station_id yang gagal. + +--- + +### Skenario 3 — Retry sukses setelah mapping diperbaiki + +- [ ] **Step 5.9 — Aktifkan kembali mapping yang dinonaktifkan di Step 5.4** + + ```sql + UPDATE m_location + SET M_LocationIsActive = 'Y' + WHERE M_LocationT_SampleStationID = ; + ``` + +- [ ] **Step 5.10 — Hit endpoint `retry_location`** + + ```bash + curl -s -X POST https:///one-api-lab/mockup/fo/ibl_registration/order/retry_location \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"order_id": }' | python3 -m json.tool + ``` + + Expected response: + ```json + { + "status": "OK", + "data": { + "order_id": , + "succeeded": [{"id": , "name": ""}], + "failed": [], + "message": "Retry selesai. 1 berhasil, 0 gagal." + } + } + ``` + +- [ ] **Step 5.11 — Verifikasi `t_order_location` kini lengkap** + + ```sql + SELECT T_OrderLocationT_SampleStationID, T_OrderLocationM_LocationID + FROM t_order_location + WHERE T_OrderLocationT_OrderHeaderID = ; + ``` + + Expected: ada row untuk semua station, termasuk yang sebelumnya gagal. + +- [ ] **Step 5.12 — Hit retry_location lagi (idempotency check)** + + ```bash + curl -s -X POST https:///one-api-lab/mockup/fo/ibl_registration/order/retry_location \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"order_id": }' | python3 -m json.tool + ``` + + Expected: + ```json + { + "status": "OK", + "data": { + "message": "Semua lokasi sudah terbentuk untuk order ini.", + "succeeded": [], + "failed": [] + } + } + ``` diff --git a/docs/superpowers/specs/2026-05-19-ibl-registration-order-location-resilience-design.md b/docs/superpowers/specs/2026-05-19-ibl-registration-order-location-resilience-design.md new file mode 100644 index 00000000..729e8787 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-ibl-registration-order-location-resilience-design.md @@ -0,0 +1,187 @@ +# Design: IBL Registration Order — Location Generation Resilience + +**Date:** 2026-05-19 +**Scope:** DEV only. No production changes. +**File:** `application/controllers/mockup/fo/ibl_registration/Order.php` +**Endpoint:** `POST /one-api-lab/mockup/fo/ibl_registration/order/save` + +--- + +## Problem + +Saat ini jika `generate_location()` gagal (mapping `m_location` aktif tidak ditemukan untuk suatu sample station), seluruh order — termasuk header, detail, dan sample yang sudah valid — ikut di-rollback. Response ke FE berupa error generik: + +> "Terjadi kesalahan saat menyimpan data lokasi" + +Akibatnya pelayanan stuck dan operator tidak tahu tindakan apa yang harus dilakukan. + +Ada juga silent bug: jika `m_location` tidak ditemukan, `$qry->row()->loc_id` bernilai null, dan INSERT tetap dijalankan dengan `loc_id = null` — bukan ditangkap sebagai "mapping tidak ada". + +--- + +## Goals + +1. Order utama (header/detail/sample) tetap tersimpan walau `generate_location` gagal. +2. Jika hanya sebagian station gagal, station lain tetap di-generate lokasinya. +3. Error message dibuat spesifik dan actionable — dicatat di backend log dan dikirim ke FE via field opsional `location_warning`. +4. Tidak ada perubahan wajib di FE (field `location_warning` opsional, diabaikan FE saat ini). +5. Endpoint retry tersedia untuk dev agar ops bisa generate ulang lokasi yang belum terbentuk. + +--- + +## Architecture + +### Perubahan Struktur Transaksi di `save()` + +**Sebelum:** +``` +trans_begin() + save_order_header() + save_order_detail() + generate_sample_lab() + generate_location() ← di dalam transaksi, gagal = rollback semua + generate_req() + update_preregister_promise() + save_delivery() +trans_commit() +``` + +**Sesudah:** +``` +trans_begin() + save_order_header() + save_order_detail() + generate_sample_lab() + generate_req() + update_preregister_promise() + save_delivery() +trans_commit() ← order tersimpan permanen di sini + +generate_location() ← di luar transaksi, mini-transaction sendiri + → sukses semua : location_warning = null + → sebagian gagal: log error, location_warning = {has_error, message, failed_stations} + → semua gagal : log error, location_warning = {has_error, message, failed_stations} + +sys_ok($dt_order) ← selalu OK, location_warning opsional +``` + +--- + +## Component Changes + +### A. `save()` (line 423–466) + +1. Hapus `$fn_generate_location` dari dalam blok transaksi. +2. Setelah `trans_commit()`, panggil `generate_location($header_id, $userid)`. +3. Evaluasi result: + - Jika `$fn_generate_location['has_failure']` true → jalankan `insert_log_error()` untuk tiap station gagal, set `$location_warning`. + - Jika semua sukses → `$location_warning = null`. +4. Tambahkan `'location_warning' => $location_warning` ke array `$dt_order`. + +### B. `generate_location($header_id, $userid)` + +Ubah logic loop station: + +- **Check null mapping:** Setelah query `m_location`, cek apakah `$qry->row()` null. Jika null → tambah ke `$failed[]` dengan `reason = 'NO_MAPPING'`, **lanjut** ke station berikutnya (jangan insert). +- **Check query error:** Jika `$qry === false` → tambah ke `$failed[]` dengan `reason = 'QUERY_ERROR'`, lanjut. +- **Check insert error:** Jika INSERT gagal → tambah ke `$failed[]` dengan `reason = 'INSERT_ERROR'`, lanjut. +- **Sukses:** Tambah ke `$succeeded[]`. + +Return structure: +```php +[ + 'has_failure' => bool, + 'succeeded' => [['id' => int, 'name' => str], ...], + 'failed' => [['id' => int, 'name' => str, 'reason' => str], ...], +] +``` + +Format pesan kegagalan (dibangun di `save()` setelah generate_location): +``` +"Order tersimpan sebagian. Gagal membuat lokasi: {NamaStation} (ID:{id}) belum punya +mapping m_location aktif. Tindakan: tambahkan mapping lokasi aktif untuk station +tersebut lalu retry generate location. OrderID: {header_id}." +``` + +### C. Logging Backend + +Untuk tiap station gagal, panggil `insert_log_error()` ke tabel `error_log_order`: +- `ErrorLogOrderCode`: `'LOCATION_NO_MAPPING'` / `'LOCATION_QUERY_ERROR'` / `'LOCATION_INSERT_ERROR'` +- `ErrorLogOrderFnName`: `'order/generate_location'` +- `ErrorLogOrderDescription`: SQL atau keterangan +- `ErrorLogOrderData`: `json_encode(['order_id' => $header_id, 'station_id' => $id, 'station_name' => $name])` + +### D. Field `location_warning` di Response + +Ditambahkan ke `$dt_order` (di dalam `data`). FE saat ini mengabaikannya; tersedia untuk update FE berikutnya. + +```json +// Jika ada kegagalan: +"location_warning": { + "has_error": true, + "message": "Order tersimpan sebagian. Gagal membuat lokasi: ...", + "failed_stations": [ + {"id": 25, "name": "Dental/Panoramic", "reason": "NO_MAPPING"} + ] +} + +// Jika semua sukses: +"location_warning": null +``` + +--- + +## New Endpoint: `retry_location` + +**Route:** `POST /mockup/fo/ibl_registration/order/retry_location` +**Method:** `retry_location()` di class `Order` + +### Input +```json +{ "order_id": 123456 } +``` + +### Flow +1. Validasi login (`isLogin`). +2. Query semua `T_SampleStationID` terkait order dari `t_orderdetail` (join chain yang sama dengan `generate_location`). +3. Filter: ambil hanya station yang **belum ada** di `t_order_location` untuk order tersebut. +4. Jalankan logic generate location (private method `_do_generate_location_for_stations($stations, $header_id, $userid)`) — sama dengan yang dipakai `generate_location`. +5. Return summary. + +### Output +```json +{ + "status": "OK", + "data": { + "order_id": 123456, + "succeeded": [{"id": 10, "name": "Lab Utama"}], + "failed": [{"id": 25, "name": "Dental/Panoramic", "reason": "NO_MAPPING"}], + "message": "Retry selesai. 1 berhasil, 1 gagal." + } +} +``` + +--- + +## Refactor Internal + +Extract logic insert location per-station ke private method `_do_generate_location_for_stations(array $stations, int $header_id, int $userid): array` — dipakai oleh `generate_location()` dan `retry_location()`. + +--- + +## UAT Scenarios (DEV) + +| Skenario | Kondisi | Expected Result | +|---|---|---| +| 1. Happy path | Semua station punya mapping aktif | `status: OK`, `location_warning: null` | +| 2. Partial failure | 1 station tanpa mapping aktif | `status: OK`, `location_warning.has_error: true`, station lain tetap tersimpan di `t_order_location` | +| 3. Retry sukses | Mapping diperbaiki, hit `retry_location` | `succeeded` berisi station yang sebelumnya gagal, `failed: []` | + +--- + +## Constraints + +- Dev only. Tidak ada perubahan production. +- Tidak ada perubahan schema DB (tabel `t_order_location` dan `error_log_order` tidak berubah). +- Tidak ada perubahan FE yang wajib. +- Perubahan hanya di `Order.php` pada path `mockup/fo/ibl_registration/`.