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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sas.fajri
2026-05-19 10:11:55 +07:00
parent b659369d9b
commit 6712b18eec
3 changed files with 1010 additions and 169 deletions

View File

@@ -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;
}
}

View File

@@ -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 21952275
- [ ] **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 21952275 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 458466)**
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 `<TOKEN>`, `<HOST>`, 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://<HOST>/one-api-lab/mockup/fo/ibl_registration/order/save \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '<payload lengkap dengan header/detail/sample>' | python3 -m json.tool
```
Expected response:
```json
{
"status": "OK",
"data": {
"status": "OK",
"order_id": <number>,
"noreg": "<string>",
"location_warning": null,
...
}
}
```
- [ ] **Step 5.3 — Verifikasi `t_order_location` terisi**
```sql
SELECT * FROM t_order_location WHERE T_OrderLocationT_OrderHeaderID = <order_id_dari_response>;
```
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 = <station_id_test>;
```
- [ ] **Step 5.5 — Hit endpoint save dengan order yang mencakup station tersebut**
```bash
curl -s -X POST https://<HOST>/one-api-lab/mockup/fo/ibl_registration/order/save \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '<payload>' | python3 -m json.tool
```
Expected response:
```json
{
"status": "OK",
"data": {
"status": "OK",
"order_id": <number>,
"location_warning": {
"has_error": true,
"message": "Order tersimpan sebagian. Gagal membuat lokasi: <NamaStation> (ID:<id>) belum punya mapping m_location aktif. Tindakan: ...",
"failed_stations": [
{"id": <station_id>, "name": "<nama>", "reason": "NO_MAPPING"}
]
},
...
}
}
```
- [ ] **Step 5.6 — Verifikasi order tersimpan di DB**
```sql
SELECT T_OrderHeaderID, T_OrderHeaderLabNumber
FROM t_orderheader WHERE T_OrderHeaderID = <order_id>;
```
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 = <order_id>;
```
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 = <station_id_test>;
```
- [ ] **Step 5.10 — Hit endpoint `retry_location`**
```bash
curl -s -X POST https://<HOST>/one-api-lab/mockup/fo/ibl_registration/order/retry_location \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"order_id": <order_id_dari_skenario_2>}' | python3 -m json.tool
```
Expected response:
```json
{
"status": "OK",
"data": {
"order_id": <number>,
"succeeded": [{"id": <station_id>, "name": "<nama>"}],
"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 = <order_id>;
```
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://<HOST>/one-api-lab/mockup/fo/ibl_registration/order/retry_location \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"order_id": <order_id>}' | python3 -m json.tool
```
Expected:
```json
{
"status": "OK",
"data": {
"message": "Semua lokasi sudah terbentuk untuk order ini.",
"succeeded": [],
"failed": []
}
}
```

View File

@@ -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 423466)
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/`.