Compare commits
1 Commits
main
...
fhm2606260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf06ca01de |
@@ -1,73 +0,0 @@
|
|||||||
@baseUrl = https://devone.aplikasi.web.id/one-api-lab
|
|
||||||
@token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJNX1VzZXJJRCI6IjMiLCJNX1VzZXJVc2VybmFtZSI6ImFkbWluICIsIk1fVXNlckdyb3VwRGFzaGJvYXJkIjoib25lLXVpLWxhYlwvdGVzdFwvdnVleFwvb25lLXBhdGllbnQtbGlzdC1iYXJjb2RlLXZ2LTYtY3BvbmVcLyIsIk1fVXNlckRlZmF1bHRUX1NhbXBsZVN0YXRpb25JRCI6IjAiLCJNX1N0YWZmTmFtZSI6IkFCSVRBIEpVV0lUQSBTQVJJIiwiaXNfY291cmllciI6Ik4iLCJ0aW1lX2F1dG9sb2dvdXQiOiIxMDAwMDAwIiwiaXAiOiIxMDMuMy4yMjAuMjIxIiwiYWdlbnQiOiJNb3ppbGxhXC81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXRcLzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZVwvMTQ5LjAuMC4wIFNhZmFyaVwvNTM3LjM2IiwidmVyc2lvbiI6InYyIiwibGFzdC1sb2dpbiI6IjIwMjYtMDYtMjIgMTE6MjM6MjkiLCJNX1NhdGVsbGl0ZUlEIjowfQ.wkQFPGQ52TeceDQARm8auj6jEb159V46BzTZ9NEE_vM
|
|
||||||
@poliId = 1
|
|
||||||
|
|
||||||
### Search Poli
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/search
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}",
|
|
||||||
"search": "",
|
|
||||||
"page": 1,
|
|
||||||
"row_per_page": 10,
|
|
||||||
"order_by": "id",
|
|
||||||
"order": "asc"
|
|
||||||
}
|
|
||||||
|
|
||||||
### Search Poli By Name
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/search
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}",
|
|
||||||
"search": "khitan",
|
|
||||||
"page": 1,
|
|
||||||
"row_per_page": 10,
|
|
||||||
"order_by": "name",
|
|
||||||
"order": "asc"
|
|
||||||
}
|
|
||||||
|
|
||||||
### Get Screening Templates
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/gettemplates
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}"
|
|
||||||
}
|
|
||||||
|
|
||||||
### Add Poli
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/add
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}",
|
|
||||||
"code": "POLI_TEST",
|
|
||||||
"name": "Poli Test",
|
|
||||||
"description": "Poli untuk test API",
|
|
||||||
"satusehat_location_id": "",
|
|
||||||
"screening_template_id": null
|
|
||||||
}
|
|
||||||
|
|
||||||
### Update Poli
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/update
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}",
|
|
||||||
"id": {{poliId}},
|
|
||||||
"code": "POLI_TEST",
|
|
||||||
"name": "Poli Test Update",
|
|
||||||
"description": "Poli untuk test API update",
|
|
||||||
"satusehat_location_id": "",
|
|
||||||
"screening_template_id": null
|
|
||||||
}
|
|
||||||
|
|
||||||
### Delete Poli
|
|
||||||
POST {{baseUrl}}/mockup/masterdata/poli/delete
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"token": "{{token}}",
|
|
||||||
"id": {{poliId}}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class Poli extends MY_Controller
|
|
||||||
{
|
|
||||||
var $db_oneklinik;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
$this->db_oneklinik = $this->load->database("onedev", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
echo "POLI API";
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error("Invalid Token");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$search = isset($prm['search']) ? trim($prm['search']) : (isset($prm['name']) ? trim($prm['name']) : '');
|
|
||||||
$like = '%' . $search . '%';
|
|
||||||
$row_per_page = isset($prm['row_per_page']) && intval($prm['row_per_page']) > 0 ? intval($prm['row_per_page']) : 10;
|
|
||||||
$page = 1;
|
|
||||||
if (isset($prm['page']) && intval($prm['page']) > 0) {
|
|
||||||
$page = intval($prm['page']);
|
|
||||||
} elseif (isset($prm['current_page']) && intval($prm['current_page']) > 0) {
|
|
||||||
$page = intval($prm['current_page']);
|
|
||||||
}
|
|
||||||
$offset = ($page - 1) * $row_per_page;
|
|
||||||
|
|
||||||
$allowed_order_by = array(
|
|
||||||
'id' => 'cu.M_ClinicUnitID',
|
|
||||||
'code' => 'cu.M_ClinicUnitCode',
|
|
||||||
'name' => 'cu.M_ClinicUnitName',
|
|
||||||
'description' => 'cu.M_ClinicUnitDescription',
|
|
||||||
'screening_template_name' => 'st.M_ScreeningTemplateName'
|
|
||||||
);
|
|
||||||
$order_by = 'cu.M_ClinicUnitID';
|
|
||||||
if (isset($prm['order_by']) && isset($allowed_order_by[$prm['order_by']])) {
|
|
||||||
$order_by = $allowed_order_by[$prm['order_by']];
|
|
||||||
}
|
|
||||||
$order = isset($prm['order']) && strtolower($prm['order']) === 'desc' ? 'DESC' : 'ASC';
|
|
||||||
|
|
||||||
$sql_count = "SELECT COUNT(*) AS total
|
|
||||||
FROM one_klinik.m_clinic_unit cu
|
|
||||||
LEFT JOIN one_klinik.m_screening_template st
|
|
||||||
ON st.M_ScreeningTemplateID = cu.M_ClinicUnitM_ScreeningTemplateID
|
|
||||||
AND st.M_ScreeningTemplateIsActive = 'Y'
|
|
||||||
WHERE cu.M_ClinicUnitIsActive = 'Y'
|
|
||||||
AND (
|
|
||||||
cu.M_ClinicUnitCode LIKE ?
|
|
||||||
OR cu.M_ClinicUnitName LIKE ?
|
|
||||||
OR IFNULL(cu.M_ClinicUnitDescription, '') LIKE ?
|
|
||||||
OR IFNULL(cu.M_ClinicUnitSatusehatLocationID, '') LIKE ?
|
|
||||||
OR IFNULL(st.M_ScreeningTemplateName, '') LIKE ?
|
|
||||||
)";
|
|
||||||
$query_count = $this->db_oneklinik->query($sql_count, array($like, $like, $like, $like, $like));
|
|
||||||
if (!$query_count) {
|
|
||||||
$this->sys_error_db("m_clinic_unit count", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$total_filter = intval($query_count->row()->total);
|
|
||||||
$total_page = ceil($total_filter / $row_per_page);
|
|
||||||
|
|
||||||
$sql = "SELECT
|
|
||||||
cu.M_ClinicUnitID AS id,
|
|
||||||
cu.M_ClinicUnitCode AS code,
|
|
||||||
cu.M_ClinicUnitName AS name,
|
|
||||||
cu.M_ClinicUnitDescription AS description,
|
|
||||||
cu.M_ClinicUnitSatusehatLocationID AS satusehat_location_id,
|
|
||||||
cu.M_ClinicUnitM_ScreeningTemplateID AS screening_template_id,
|
|
||||||
st.M_ScreeningTemplateCode AS screening_template_code,
|
|
||||||
st.M_ScreeningTemplateName AS screening_template_name,
|
|
||||||
cu.M_ClinicUnitIsActive AS is_active,
|
|
||||||
cu.M_ClinicUnitCreated AS created,
|
|
||||||
cu.M_ClinicUnitLastUpdated AS last_updated
|
|
||||||
FROM one_klinik.m_clinic_unit cu
|
|
||||||
LEFT JOIN one_klinik.m_screening_template st
|
|
||||||
ON st.M_ScreeningTemplateID = cu.M_ClinicUnitM_ScreeningTemplateID
|
|
||||||
AND st.M_ScreeningTemplateIsActive = 'Y'
|
|
||||||
WHERE cu.M_ClinicUnitIsActive = 'Y'
|
|
||||||
AND (
|
|
||||||
cu.M_ClinicUnitCode LIKE ?
|
|
||||||
OR cu.M_ClinicUnitName LIKE ?
|
|
||||||
OR IFNULL(cu.M_ClinicUnitDescription, '') LIKE ?
|
|
||||||
OR IFNULL(cu.M_ClinicUnitSatusehatLocationID, '') LIKE ?
|
|
||||||
OR IFNULL(st.M_ScreeningTemplateName, '') LIKE ?
|
|
||||||
)
|
|
||||||
ORDER BY {$order_by} {$order}
|
|
||||||
LIMIT ? OFFSET ?";
|
|
||||||
$query = $this->db_oneklinik->query($sql, array($like, $like, $like, $like, $like, $row_per_page, $offset));
|
|
||||||
if (!$query) {
|
|
||||||
$this->sys_error_db("m_clinic_unit select", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $query->result_array();
|
|
||||||
$this->sys_ok(array(
|
|
||||||
"total" => $total_page,
|
|
||||||
"total_filter" => $total_filter,
|
|
||||||
"records" => $rows
|
|
||||||
));
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
$this->sys_error($exc->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function gettemplates()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error("Invalid Token");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "SELECT
|
|
||||||
M_ScreeningTemplateID AS id,
|
|
||||||
M_ScreeningTemplateCode AS code,
|
|
||||||
M_ScreeningTemplateName AS name,
|
|
||||||
M_ScreeningTemplateDescription AS description
|
|
||||||
FROM one_klinik.m_screening_template
|
|
||||||
WHERE M_ScreeningTemplateIsActive = 'Y'
|
|
||||||
ORDER BY M_ScreeningTemplateName ASC";
|
|
||||||
$query = $this->db_oneklinik->query($sql);
|
|
||||||
if (!$query) {
|
|
||||||
$this->sys_error_db("m_screening_template select", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $query->result_array();
|
|
||||||
$this->sys_ok(array("total" => count($rows), "records" => $rows));
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
$this->sys_error($exc->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function add()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error("Invalid Token");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$code = isset($prm['code']) ? trim($prm['code']) : '';
|
|
||||||
$name = isset($prm['name']) ? trim($prm['name']) : '';
|
|
||||||
$description = isset($prm['description']) ? trim($prm['description']) : null;
|
|
||||||
$satusehat_location_id = isset($prm['satusehat_location_id']) ? trim($prm['satusehat_location_id']) : null;
|
|
||||||
$screening_template_id = isset($prm['screening_template_id']) && $prm['screening_template_id'] !== '' ? intval($prm['screening_template_id']) : null;
|
|
||||||
$userid = $this->sys_user["M_UserID"];
|
|
||||||
|
|
||||||
if ($code === '' || $name === '') {
|
|
||||||
$this->sys_error("code and name are mandatory");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$duplicate = $this->db_oneklinik->query(
|
|
||||||
"SELECT COUNT(*) AS total
|
|
||||||
FROM one_klinik.m_clinic_unit
|
|
||||||
WHERE M_ClinicUnitCode = ?
|
|
||||||
OR (M_ClinicUnitIsActive = 'Y' AND M_ClinicUnitName = ?)",
|
|
||||||
array($code, $name)
|
|
||||||
);
|
|
||||||
if (!$duplicate) {
|
|
||||||
$this->sys_error_db("m_clinic_unit duplicate check", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if (intval($duplicate->row()->total) > 0) {
|
|
||||||
$this->sys_ok(array(
|
|
||||||
"total" => -1,
|
|
||||||
"errors" => array(array("field" => "code", "msg" => "Kode atau nama sudah ada")),
|
|
||||||
"records" => 0
|
|
||||||
));
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "INSERT INTO one_klinik.m_clinic_unit (
|
|
||||||
M_ClinicUnitCode,
|
|
||||||
M_ClinicUnitName,
|
|
||||||
M_ClinicUnitDescription,
|
|
||||||
M_ClinicUnitSatusehatLocationID,
|
|
||||||
M_ClinicUnitM_ScreeningTemplateID,
|
|
||||||
M_ClinicUnitUserID,
|
|
||||||
M_ClinicUnitCreated,
|
|
||||||
M_ClinicUnitLastUpdated
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())";
|
|
||||||
$query = $this->db_oneklinik->query($sql, array(
|
|
||||||
$code,
|
|
||||||
$name,
|
|
||||||
$description,
|
|
||||||
$satusehat_location_id,
|
|
||||||
$screening_template_id,
|
|
||||||
$userid
|
|
||||||
));
|
|
||||||
if (!$query) {
|
|
||||||
$this->sys_error_db("m_clinic_unit insert", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sys_ok(array(
|
|
||||||
"total" => 1,
|
|
||||||
"records" => array("xid" => $this->db_oneklinik->insert_id())
|
|
||||||
));
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
$this->sys_error($exc->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error("Invalid Token");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$id = isset($prm['id']) ? intval($prm['id']) : 0;
|
|
||||||
$code = isset($prm['code']) ? trim($prm['code']) : '';
|
|
||||||
$name = isset($prm['name']) ? trim($prm['name']) : '';
|
|
||||||
$description = isset($prm['description']) ? trim($prm['description']) : null;
|
|
||||||
$satusehat_location_id = isset($prm['satusehat_location_id']) ? trim($prm['satusehat_location_id']) : null;
|
|
||||||
$screening_template_id = isset($prm['screening_template_id']) && $prm['screening_template_id'] !== '' ? intval($prm['screening_template_id']) : null;
|
|
||||||
$userid = $this->sys_user["M_UserID"];
|
|
||||||
|
|
||||||
if (!$id || $code === '' || $name === '') {
|
|
||||||
$this->sys_error("id, code and name are mandatory");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$duplicate = $this->db_oneklinik->query(
|
|
||||||
"SELECT COUNT(*) AS total
|
|
||||||
FROM one_klinik.m_clinic_unit
|
|
||||||
WHERE M_ClinicUnitID <> ?
|
|
||||||
AND (M_ClinicUnitCode = ?
|
|
||||||
OR (M_ClinicUnitIsActive = 'Y' AND M_ClinicUnitName = ?))",
|
|
||||||
array($id, $code, $name)
|
|
||||||
);
|
|
||||||
if (!$duplicate) {
|
|
||||||
$this->sys_error_db("m_clinic_unit duplicate check", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if (intval($duplicate->row()->total) > 0) {
|
|
||||||
$this->sys_ok(array(
|
|
||||||
"total" => -1,
|
|
||||||
"errors" => array(array("field" => "code", "msg" => "Kode atau nama sudah ada")),
|
|
||||||
"records" => 0
|
|
||||||
));
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "UPDATE one_klinik.m_clinic_unit SET
|
|
||||||
M_ClinicUnitCode = ?,
|
|
||||||
M_ClinicUnitName = ?,
|
|
||||||
M_ClinicUnitDescription = ?,
|
|
||||||
M_ClinicUnitSatusehatLocationID = ?,
|
|
||||||
M_ClinicUnitM_ScreeningTemplateID = ?,
|
|
||||||
M_ClinicUnitUserID = ?,
|
|
||||||
M_ClinicUnitLastUpdated = NOW()
|
|
||||||
WHERE M_ClinicUnitID = ?
|
|
||||||
AND M_ClinicUnitIsActive = 'Y'";
|
|
||||||
$query = $this->db_oneklinik->query($sql, array(
|
|
||||||
$code,
|
|
||||||
$name,
|
|
||||||
$description,
|
|
||||||
$satusehat_location_id,
|
|
||||||
$screening_template_id,
|
|
||||||
$userid,
|
|
||||||
$id
|
|
||||||
));
|
|
||||||
if (!$query) {
|
|
||||||
$this->sys_error_db("m_clinic_unit update", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sys_ok(array("total" => 1, "records" => array("xid" => $id)));
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
$this->sys_error($exc->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error("Invalid Token");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$id = isset($prm['id']) ? intval($prm['id']) : 0;
|
|
||||||
if (!$id) {
|
|
||||||
$this->sys_error("id is mandatory");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
$userid = $this->sys_user["M_UserID"];
|
|
||||||
|
|
||||||
$sql = "UPDATE one_klinik.m_clinic_unit SET
|
|
||||||
M_ClinicUnitIsActive = 'N',
|
|
||||||
M_ClinicUnitUserID = ?,
|
|
||||||
M_ClinicUnitLastUpdated = NOW()
|
|
||||||
WHERE M_ClinicUnitID = ?";
|
|
||||||
$query = $this->db_oneklinik->query($sql, array($userid, $id));
|
|
||||||
if (!$query) {
|
|
||||||
$this->sys_error_db("m_clinic_unit delete", $this->db_oneklinik);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sys_ok(array("total" => 1, "records" => array("xid" => $id)));
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
$this->sys_error($exc->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -539,8 +539,6 @@ class Birt_proxy extends MY_Controller
|
|||||||
|
|
||||||
$umur = $dob . ' / ' . ($row['T_OrderHeaderM_PatientAge'] ?? '');
|
$umur = $dob . ' / ' . ($row['T_OrderHeaderM_PatientAge'] ?? '');
|
||||||
|
|
||||||
$this->_populate_cache($order_id);
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'T_OrderHeaderDate' => $row['T_OrderHeaderDate'] ?? '',
|
'T_OrderHeaderDate' => $row['T_OrderHeaderDate'] ?? '',
|
||||||
'T_OrderHeaderLabNumber' => $row['T_OrderHeaderLabNumber'] ?? '',
|
'T_OrderHeaderLabNumber' => $row['T_OrderHeaderLabNumber'] ?? '',
|
||||||
|
|||||||
@@ -1,598 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('BASEPATH') or exit('No direct script access allowed');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Birt_proxy — PHP proxy untuk semua BIRT report call
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Terima request dari frontend (report_code + params)
|
|
||||||
* 2. Decrypt patient PII dari _enc
|
|
||||||
* 3. INSERT ke patient_print_cache
|
|
||||||
* 4. Call BIRT via file_get_contents (internal)
|
|
||||||
* 5. DELETE cache
|
|
||||||
* 6. Stream PDF ke frontend
|
|
||||||
*
|
|
||||||
* Endpoint: POST /tools/birt_proxy/stream
|
|
||||||
* Params : report_code, PT_OrderHeaderID, PUsername, (optional) PID_patient
|
|
||||||
*/
|
|
||||||
class Birt_proxy extends MY_Controller
|
|
||||||
{
|
|
||||||
public $db_onedev;
|
|
||||||
private $birt_base = 'http://localhost:8080';
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
$this->db_onedev = $this->load->database('onedev', true);
|
|
||||||
$this->load->library('ibl_encryptor');
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET/POST /tools/birt_proxy/stream_by_code
|
|
||||||
// Gunakan ini untuk flow browser print yang butuh URL langsung,
|
|
||||||
// tapi cache PDP harus tetap dihapus segera setelah PDF di-stream.
|
|
||||||
public function stream_by_code()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = trim($prm['report_code'] ?? $prm['code_report'] ?? $prm['code'] ?? '');
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? $prm['order_id'] ?? 0);
|
|
||||||
$payment_id = intval($prm['PPaymentID'] ?? $prm['payment_id'] ?? 0);
|
|
||||||
|
|
||||||
if (!$report_code) {
|
|
||||||
$this->sys_error('report_code wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($order_id <= 0 && $payment_id > 0) {
|
|
||||||
$order_id = $this->_resolve_order_id_by_payment($payment_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment_id <= 0 && $order_id > 0) {
|
|
||||||
$payment_id = $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($order_id <= 0) {
|
|
||||||
$this->sys_error('order_id tidak ditemukan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->_delete_cache($cache_id);
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$full_url = $this->_resolve_fetch_url($url);
|
|
||||||
$context = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'method' => 'GET',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdf = @file_get_contents($full_url, false, $context);
|
|
||||||
$this->_delete_cache($cache_id);
|
|
||||||
|
|
||||||
if ($pdf === false) {
|
|
||||||
$this->sys_error('Gagal generate report dari BIRT server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = $report_code . '_' . $order_id . '_' . date('Ymd') . '.pdf';
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
|
||||||
header('Content-Length: ' . strlen($pdf));
|
|
||||||
echo $pdf;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /tools/birt_proxy/stream
|
|
||||||
public function stream()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = $prm['report_code'] ?? '';
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? 0);
|
|
||||||
$payment_id = intval($prm['PPaymentID'] ?? 0);
|
|
||||||
|
|
||||||
if (!$report_code) {
|
|
||||||
$this->sys_error('report_code wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment_id <= 0 && $order_id > 0) {
|
|
||||||
$payment_id = $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$patient_name = '';
|
|
||||||
if ($order_id > 0) {
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$cache_id = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$full_url = $this->_resolve_fetch_url($url);
|
|
||||||
|
|
||||||
$context = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'method' => 'GET',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdf = @file_get_contents($full_url, false, $context);
|
|
||||||
|
|
||||||
if ($cache_id) {
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_id = ?",
|
|
||||||
[$cache_id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pdf === false) {
|
|
||||||
$this->sys_error('Gagal generate report dari BIRT server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = $report_code . '_' . $order_id . '_' . date('Ymd') . '.pdf';
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
|
||||||
header('Content-Length: ' . strlen($pdf));
|
|
||||||
echo $pdf;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hanya return URL (untuk iframe/window.open) — tanpa stream
|
|
||||||
// Frontend membuka URL ini secara langsung
|
|
||||||
public function get_url()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = $prm['report_code'] ?? '';
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? 0);
|
|
||||||
$patient_name = '';
|
|
||||||
if ($order_id > 0) {
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, 0, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sys_ok(['url' => $url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt patient PII dan simpan ke cache
|
|
||||||
private function _populate_cache($order_id)
|
|
||||||
{
|
|
||||||
// Ambil _enc columns dari m_patient via t_orderheader
|
|
||||||
$patient = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientID,
|
|
||||||
M_PatientName_enc, M_PatientDOB_enc, M_PatientHP_enc,
|
|
||||||
M_PatientEmail_enc, M_PatientDOB
|
|
||||||
FROM t_orderheader
|
|
||||||
JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID
|
|
||||||
WHERE T_OrderHeaderID = ? LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
if (!$patient) return null;
|
|
||||||
|
|
||||||
$addr = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientAddressDescription_enc
|
|
||||||
FROM m_patientaddress
|
|
||||||
WHERE M_PatientAddressM_PatientID = ?
|
|
||||||
AND M_PatientAddressIsActive = 'Y'
|
|
||||||
AND M_PatientAddressNote = 'Utama'
|
|
||||||
LIMIT 1",
|
|
||||||
[$patient['M_PatientID']]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
$enc = $this->ibl_encryptor;
|
|
||||||
$name = $enc->decrypt($patient['M_PatientName_enc'] ?? '') ?? '';
|
|
||||||
$dob = $enc->decrypt($patient['M_PatientDOB_enc'] ?? '') ?? date('d-m-Y', strtotime($patient['M_PatientDOB'] ?? 'now'));
|
|
||||||
$hp = $enc->decrypt($patient['M_PatientHP_enc'] ?? '') ?? '';
|
|
||||||
$email= $enc->decrypt($patient['M_PatientEmail_enc']?? '') ?? '';
|
|
||||||
$address = $enc->decrypt($addr['M_PatientAddressDescription_enc'] ?? '') ?? '';
|
|
||||||
|
|
||||||
// Hapus cache lama untuk order ini + cleanup expired
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_order_id = ? OR ppc_created < NOW() - INTERVAL 5 MINUTE",
|
|
||||||
[$order_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert cache baru
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"INSERT INTO patient_print_cache
|
|
||||||
(ppc_order_id, ppc_patient_id, ppc_name, ppc_dob, ppc_hp, ppc_email, ppc_address, ppc_created)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())",
|
|
||||||
[$order_id, $patient['M_PatientID'], $name, $dob, $hp, $email, $address]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->db_onedev->insert_id();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _delete_cache($cache_id)
|
|
||||||
{
|
|
||||||
if ($cache_id) {
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_id = ?",
|
|
||||||
[$cache_id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_created < NOW() - INTERVAL 5 MINUTE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_fetch_url($url)
|
|
||||||
{
|
|
||||||
$url = trim((string) $url);
|
|
||||||
|
|
||||||
if ($url === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^https?://#i', $url)) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/birt/') === 0) {
|
|
||||||
return $this->birt_base . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/one-api-lab/') === 0) {
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
|
||||||
|
|
||||||
return $scheme . '://' . $host . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/tools/') === 0 || strpos($url, '/index.php/') === 0) {
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
|
||||||
|
|
||||||
return $scheme . '://' . $host . '/one-api-lab' . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->birt_base . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_order_id_by_payment($payment_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT F_PaymentT_OrderHeaderID
|
|
||||||
FROM f_payment
|
|
||||||
WHERE F_PaymentID = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$payment_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return intval($row['F_PaymentT_OrderHeaderID'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_payment_id_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT F_PaymentID
|
|
||||||
FROM f_payment
|
|
||||||
WHERE F_PaymentT_OrderHeaderID = ?
|
|
||||||
ORDER BY F_PaymentID DESC
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return intval($row['F_PaymentID'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_by_cache($cache_id)
|
|
||||||
{
|
|
||||||
if (!$cache_id) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT ppc_name
|
|
||||||
FROM patient_print_cache
|
|
||||||
WHERE ppc_id = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$cache_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($row['ppc_name'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT ppc_name
|
|
||||||
FROM patient_print_cache
|
|
||||||
WHERE ppc_order_id = ?
|
|
||||||
ORDER BY ppc_id DESC
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($row['ppc_name'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_from_enc_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientName_enc
|
|
||||||
FROM t_orderheader
|
|
||||||
JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID
|
|
||||||
WHERE T_OrderHeaderID = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($this->ibl_encryptor->decrypt($row['M_PatientName_enc'] ?? '') ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_report_username()
|
|
||||||
{
|
|
||||||
if (!empty($this->sys_user['M_StaffName'])) {
|
|
||||||
return trim($this->sys_user['M_StaffName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($this->sys_user['M_UserUsername'])) {
|
|
||||||
return trim($this->sys_user['M_UserUsername']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($this->sys_user['userName'])) {
|
|
||||||
return trim($this->sys_user['userName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ADMIN';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT Print_TransactionUrl
|
|
||||||
FROM print_transaction
|
|
||||||
WHERE Print_TransactionCode = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$report_code]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
if (!$row) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url_template = $this->_apply_report_template_hotfix($report_code, $row['Print_TransactionUrl']);
|
|
||||||
$username = $this->_resolve_report_username();
|
|
||||||
$tm = round(microtime(true) * 1000);
|
|
||||||
$resolved_payment_id = $payment_id > 0 ? $payment_id : $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
$is_internal_app_url = $this->_is_internal_app_url($url_template);
|
|
||||||
|
|
||||||
$replacements = [
|
|
||||||
'PUsername' => $this->_format_report_string_param($username, $is_internal_app_url),
|
|
||||||
'PT_OrderHeaderID' => $order_id,
|
|
||||||
'PPaymentID' => $resolved_payment_id,
|
|
||||||
'PAn' => $this->_format_report_string_param($patient_name, $is_internal_app_url),
|
|
||||||
'TS' => $tm,
|
|
||||||
];
|
|
||||||
|
|
||||||
$url = $url_template;
|
|
||||||
foreach ($replacements as $placeholder => $value) {
|
|
||||||
if ($value === null) {
|
|
||||||
$value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = str_replace($placeholder, $value, $url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _apply_report_template_hotfix($report_code, $url_template)
|
|
||||||
{
|
|
||||||
$print_report_hotfix = [
|
|
||||||
'LAB-RESULT-P-01' => [
|
|
||||||
'from' => 'rpt_test.rptdesign',
|
|
||||||
'to' => 'rpt_test_bkp020626.rptdesign',
|
|
||||||
],
|
|
||||||
'MIKROO-RESULT-P-01' => [
|
|
||||||
'from' => 'rpt_test.rptdesign',
|
|
||||||
'to' => 'rpt_test_bkp020626.rptdesign',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!isset($print_report_hotfix[$report_code])) {
|
|
||||||
return $url_template;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hotfix = $print_report_hotfix[$report_code];
|
|
||||||
|
|
||||||
$resolved_url = str_replace($hotfix['from'], $hotfix['to'], $url_template);
|
|
||||||
|
|
||||||
if (strpos($resolved_url, 'username=') === false) {
|
|
||||||
$resolved_url .= (strpos($resolved_url, '?') === false ? '?' : '&') . 'username=PUsername';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolved_url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /tools/birt_proxy/header_json?PID=<order_id>
|
|
||||||
// Hanya bisa diakses dari localhost (127.0.0.1) — dipanggil oleh BIRT scripted dataset
|
|
||||||
// Return JSON semua kolom sp_rpt_hasil_header, PII sudah di-decrypt
|
|
||||||
public function header_json()
|
|
||||||
{
|
|
||||||
$order_id = intval($this->input->get('PID') ?? 0);
|
|
||||||
if ($order_id <= 0) {
|
|
||||||
echo json_encode(['error' => 'PID required']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->db_onedev->query("
|
|
||||||
SELECT
|
|
||||||
DATE_FORMAT(T_OrderHeaderDate, '%d-%m-%Y') AS T_OrderHeaderDate,
|
|
||||||
T_OrderHeaderLabNumber,
|
|
||||||
M_TitleName,
|
|
||||||
M_PatientName,
|
|
||||||
M_PatientName_enc,
|
|
||||||
m_sexname AS Gender,
|
|
||||||
M_PatientNoReg,
|
|
||||||
M_PatientDOB,
|
|
||||||
M_PatientDOB_enc,
|
|
||||||
T_OrderHeaderM_PatientAge,
|
|
||||||
M_CompanyName AS CorporateName,
|
|
||||||
M_PatientHp,
|
|
||||||
M_PatientHP_enc,
|
|
||||||
M_PatientEmail,
|
|
||||||
M_PatientEmail_enc,
|
|
||||||
'' AS M_PatientAddressCity,
|
|
||||||
'' AS M_PatientAddressState,
|
|
||||||
M_CompanyName AS CorporateAddress,
|
|
||||||
M_CompanyEmail AS CorporateEmail,
|
|
||||||
M_CompanyPhone AS CorporatePhone,
|
|
||||||
M_CompanyAddressCity AS CorporateAddressCity,
|
|
||||||
'' AS CorporateAddressState,
|
|
||||||
TRIM(CONCAT(IFNULL(pj.M_DoctorPrefix,''),' ',IFNULL(pj.M_DoctorPrefix2,''),' ',IFNULL(pj.M_DoctorName,''),' ',IFNULL(pj.M_DoctorSufix,''),' ',IFNULL(pj.M_DoctorSufix2,''))) AS M_DoctorName,
|
|
||||||
TRIM(CONCAT(IFNULL(pjj.M_DoctorPrefix,''),' ',IFNULL(pjj.M_DoctorPrefix2,''),' ',IFNULL(pjj.M_DoctorName,''),' ',IFNULL(pjj.M_DoctorSufix,''),' ',IFNULL(pjj.M_DoctorSufix2,''))) AS M_DoctorName2,
|
|
||||||
M_PatientID,
|
|
||||||
M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation,
|
|
||||||
CONCAT(IFNULL(M_PatientDepartement,''),' - ',IFNULL(M_PatientNIP,'')) AS M_PatientDepartement
|
|
||||||
FROM t_orderheader
|
|
||||||
LEFT JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID AND M_PatientIsActive = 'Y'
|
|
||||||
LEFT JOIN m_sex ON M_PatientM_SexID = M_SexID
|
|
||||||
LEFT JOIN m_title ON M_PatientM_TitleID = M_TitleID AND M_TitleIsActive = 'Y'
|
|
||||||
JOIN m_company ON T_OrderHeaderM_CompanyID = M_CompanyID AND M_CompanyIsActive = 'Y'
|
|
||||||
LEFT JOIN m_doctor pjj ON T_OrderHeaderPj2M_DoctorID = pjj.M_DoctorID AND pjj.M_DoctorIsActive = 'Y'
|
|
||||||
LEFT JOIN m_doctor pj ON T_OrderHeaderPjM_DoctorID = pj.M_DoctorID AND pj.M_DoctorIsActive = 'Y'
|
|
||||||
WHERE T_OrderHeaderID = ? AND T_OrderHeaderIsActive = 'Y'
|
|
||||||
", [$order_id])->row_array();
|
|
||||||
|
|
||||||
if (!$row) {
|
|
||||||
echo json_encode(['error' => 'order not found']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$enc = $this->ibl_encryptor;
|
|
||||||
$name = $enc->decrypt($row['M_PatientName_enc'] ?? '') ?: ($row['M_PatientName'] ?? '');
|
|
||||||
$dob = $enc->decrypt($row['M_PatientDOB_enc'] ?? '') ?: date('d-m-Y', strtotime($row['M_PatientDOB'] ?? 'now'));
|
|
||||||
$hp = $enc->decrypt($row['M_PatientHP_enc'] ?? '') ?: ($row['M_PatientHp'] ?? '');
|
|
||||||
$email= $enc->decrypt($row['M_PatientEmail_enc']?? '') ?: ($row['M_PatientEmail'] ?? '');
|
|
||||||
|
|
||||||
$addr_row = $this->db_onedev->query("
|
|
||||||
SELECT CONCAT(
|
|
||||||
IFNULL(M_PatientAddressDescription,''),' ',
|
|
||||||
IFNULL((SELECT regional_nm FROM regional WHERE regional_cd = NULLIF(TRIM(M_PatientAddressRegionalCd),'') LIMIT 1),'')
|
|
||||||
) AS addr,
|
|
||||||
M_PatientAddressDescription_enc
|
|
||||||
FROM m_patientaddress
|
|
||||||
WHERE M_PatientAddressM_PatientID = ? AND M_PatientAddressIsActive = 'Y'
|
|
||||||
ORDER BY M_PatientAddressID LIMIT 1
|
|
||||||
", [$row['M_PatientID']])->row_array();
|
|
||||||
|
|
||||||
$address = '';
|
|
||||||
if ($addr_row) {
|
|
||||||
$address = $enc->decrypt($addr_row['M_PatientAddressDescription_enc'] ?? '') ?: trim($addr_row['addr'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$umur = $dob . ' / ' . ($row['T_OrderHeaderM_PatientAge'] ?? '');
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'T_OrderHeaderDate' => $row['T_OrderHeaderDate'] ?? '',
|
|
||||||
'T_OrderHeaderLabNumber' => $row['T_OrderHeaderLabNumber'] ?? '',
|
|
||||||
'M_PatientName' => trim(($row['M_TitleName'] ?? '') . '. ' . $name),
|
|
||||||
'Gender' => $row['Gender'] ?? '',
|
|
||||||
'M_PatientNoReg' => $row['M_PatientNoReg'] ?? '',
|
|
||||||
'M_PatientDOB' => $dob,
|
|
||||||
'T_OrderHeaderM_PatientAge' => $row['T_OrderHeaderM_PatientAge'] ?? '',
|
|
||||||
'CorporateName' => $row['CorporateName'] ?? '',
|
|
||||||
'M_PatientAddress' => $address,
|
|
||||||
'M_PatientHp' => $hp,
|
|
||||||
'M_PatientEmail' => $email,
|
|
||||||
'M_PatientAddressCity' => '',
|
|
||||||
'M_PatientAddressState' => '',
|
|
||||||
'CorporateAddress' => $row['CorporateAddress'] ?? '',
|
|
||||||
'CorporateEmail' => $row['CorporateEmail'] ?? '',
|
|
||||||
'CorporatePhone' => $row['CorporatePhone'] ?? '',
|
|
||||||
'CorporateAddressCity' => $row['CorporateAddressCity'] ?? '',
|
|
||||||
'CorporateAddressState' => '',
|
|
||||||
'M_DoctorName' => $row['M_DoctorName'] ?? '',
|
|
||||||
'M_DoctorName2' => $row['M_DoctorName2'] ?? '',
|
|
||||||
'Umur' => $umur,
|
|
||||||
'M_PatientNIP' => $row['M_PatientNIP'] ?? '',
|
|
||||||
'M_PatientJob' => $row['M_PatientJob'] ?? '',
|
|
||||||
'M_PatientPosisi' => $row['M_PatientPosisi'] ?? '',
|
|
||||||
'M_PatientDivisi' => $row['M_PatientDivisi'] ?? '',
|
|
||||||
'M_PatientLocation' => $row['M_PatientLocation'] ?? '',
|
|
||||||
'M_PatientDepartement' => $row['M_PatientDepartement'] ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode($data);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _is_internal_app_url($url)
|
|
||||||
{
|
|
||||||
$url = (string) $url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
strpos($url, '/one-api-lab/') === 0 ||
|
|
||||||
strpos($url, '/tools/') === 0 ||
|
|
||||||
strpos($url, '/index.php/') === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _format_report_string_param($value, $is_internal_app_url = false)
|
|
||||||
{
|
|
||||||
$value = (string) $value;
|
|
||||||
|
|
||||||
if ($is_internal_app_url) {
|
|
||||||
return rawurlencode($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawurlencode("'" . $value . "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('BASEPATH') or exit('No direct script access allowed');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Birt_proxy — PHP proxy untuk semua BIRT report call
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Terima request dari frontend (report_code + params)
|
|
||||||
* 2. Decrypt patient PII dari _enc
|
|
||||||
* 3. INSERT ke patient_print_cache
|
|
||||||
* 4. Call BIRT via file_get_contents (internal)
|
|
||||||
* 5. DELETE cache
|
|
||||||
* 6. Stream PDF ke frontend
|
|
||||||
*
|
|
||||||
* Endpoint: POST /tools/birt_proxy/stream
|
|
||||||
* Params : report_code, PT_OrderHeaderID, PUsername, (optional) PID_patient
|
|
||||||
*/
|
|
||||||
class Birt_proxy extends MY_Controller
|
|
||||||
{
|
|
||||||
public $db_onedev;
|
|
||||||
private $birt_base = 'http://localhost:8080';
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
$this->db_onedev = $this->load->database('onedev', true);
|
|
||||||
$this->load->library('ibl_encryptor');
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET/POST /tools/birt_proxy/stream_by_code
|
|
||||||
// Gunakan ini untuk flow browser print yang butuh URL langsung,
|
|
||||||
// tapi cache PDP harus tetap dihapus segera setelah PDF di-stream.
|
|
||||||
public function stream_by_code()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = trim($prm['report_code'] ?? $prm['code_report'] ?? $prm['code'] ?? '');
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? $prm['order_id'] ?? 0);
|
|
||||||
$payment_id = intval($prm['PPaymentID'] ?? $prm['payment_id'] ?? 0);
|
|
||||||
|
|
||||||
if (!$report_code) {
|
|
||||||
$this->sys_error('report_code wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($order_id <= 0 && $payment_id > 0) {
|
|
||||||
$order_id = $this->_resolve_order_id_by_payment($payment_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment_id <= 0 && $order_id > 0) {
|
|
||||||
$payment_id = $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($order_id <= 0) {
|
|
||||||
$this->sys_error('order_id tidak ditemukan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->_delete_cache($cache_id);
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$full_url = $this->_resolve_fetch_url($url);
|
|
||||||
$context = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'method' => 'GET',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdf = @file_get_contents($full_url, false, $context);
|
|
||||||
$this->_delete_cache($cache_id);
|
|
||||||
|
|
||||||
if ($pdf === false) {
|
|
||||||
$this->sys_error('Gagal generate report dari BIRT server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = $report_code . '_' . $order_id . '_' . date('Ymd') . '.pdf';
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
|
||||||
header('Content-Length: ' . strlen($pdf));
|
|
||||||
echo $pdf;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /tools/birt_proxy/stream
|
|
||||||
public function stream()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = $prm['report_code'] ?? '';
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? 0);
|
|
||||||
$payment_id = intval($prm['PPaymentID'] ?? 0);
|
|
||||||
|
|
||||||
if (!$report_code) {
|
|
||||||
$this->sys_error('report_code wajib diisi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment_id <= 0 && $order_id > 0) {
|
|
||||||
$payment_id = $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$patient_name = '';
|
|
||||||
if ($order_id > 0) {
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$cache_id = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$full_url = $this->_resolve_fetch_url($url);
|
|
||||||
|
|
||||||
$context = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'method' => 'GET',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdf = @file_get_contents($full_url, false, $context);
|
|
||||||
|
|
||||||
if ($cache_id) {
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_id = ?",
|
|
||||||
[$cache_id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pdf === false) {
|
|
||||||
$this->sys_error('Gagal generate report dari BIRT server');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = $report_code . '_' . $order_id . '_' . date('Ymd') . '.pdf';
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
|
||||||
header('Content-Length: ' . strlen($pdf));
|
|
||||||
echo $pdf;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hanya return URL (untuk iframe/window.open) — tanpa stream
|
|
||||||
// Frontend membuka URL ini secara langsung
|
|
||||||
public function get_url()
|
|
||||||
{
|
|
||||||
if (!$this->isLogin) {
|
|
||||||
$this->sys_error('Invalid Token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prm = $this->sys_input;
|
|
||||||
$report_code = $prm['report_code'] ?? '';
|
|
||||||
$order_id = intval($prm['PT_OrderHeaderID'] ?? 0);
|
|
||||||
$patient_name = '';
|
|
||||||
if ($order_id > 0) {
|
|
||||||
$cache_id = $this->_populate_cache($order_id);
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_cache($cache_id);
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_by_order($order_id);
|
|
||||||
}
|
|
||||||
if ($patient_name === '') {
|
|
||||||
$patient_name = $this->_resolve_patient_name_from_enc_by_order($order_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->_build_birt_url_by_code($report_code, $order_id, 0, $patient_name);
|
|
||||||
if ($url === false) {
|
|
||||||
$this->sys_error("Report code tidak ditemukan: {$report_code}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sys_ok(['url' => $url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt patient PII dan simpan ke cache
|
|
||||||
private function _populate_cache($order_id)
|
|
||||||
{
|
|
||||||
// Ambil _enc columns dari m_patient via t_orderheader
|
|
||||||
$patient = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientID,
|
|
||||||
M_PatientName_enc, M_PatientDOB_enc, M_PatientHP_enc,
|
|
||||||
M_PatientEmail_enc, M_PatientDOB
|
|
||||||
FROM t_orderheader
|
|
||||||
JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID
|
|
||||||
WHERE T_OrderHeaderID = ? LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
if (!$patient) return null;
|
|
||||||
|
|
||||||
$addr = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientAddressDescription_enc
|
|
||||||
FROM m_patientaddress
|
|
||||||
WHERE M_PatientAddressM_PatientID = ?
|
|
||||||
AND M_PatientAddressIsActive = 'Y'
|
|
||||||
AND M_PatientAddressNote = 'Utama'
|
|
||||||
LIMIT 1",
|
|
||||||
[$patient['M_PatientID']]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
$enc = $this->ibl_encryptor;
|
|
||||||
$name = $enc->decrypt($patient['M_PatientName_enc'] ?? '') ?? '';
|
|
||||||
$dob = $enc->decrypt($patient['M_PatientDOB_enc'] ?? '') ?? date('d-m-Y', strtotime($patient['M_PatientDOB'] ?? 'now'));
|
|
||||||
$hp = $enc->decrypt($patient['M_PatientHP_enc'] ?? '') ?? '';
|
|
||||||
$email= $enc->decrypt($patient['M_PatientEmail_enc']?? '') ?? '';
|
|
||||||
$address = $enc->decrypt($addr['M_PatientAddressDescription_enc'] ?? '') ?? '';
|
|
||||||
|
|
||||||
// Hapus cache lama untuk order ini + cleanup expired
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_order_id = ? OR ppc_created < NOW() - INTERVAL 5 MINUTE",
|
|
||||||
[$order_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert cache baru
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"INSERT INTO patient_print_cache
|
|
||||||
(ppc_order_id, ppc_patient_id, ppc_name, ppc_dob, ppc_hp, ppc_email, ppc_address, ppc_created)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())",
|
|
||||||
[$order_id, $patient['M_PatientID'], $name, $dob, $hp, $email, $address]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->db_onedev->insert_id();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _delete_cache($cache_id)
|
|
||||||
{
|
|
||||||
if ($cache_id) {
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_id = ?",
|
|
||||||
[$cache_id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db_onedev->query(
|
|
||||||
"DELETE FROM patient_print_cache WHERE ppc_created < NOW() - INTERVAL 5 MINUTE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_fetch_url($url)
|
|
||||||
{
|
|
||||||
$url = trim((string) $url);
|
|
||||||
|
|
||||||
if ($url === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^https?://#i', $url)) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/birt/') === 0) {
|
|
||||||
return $this->birt_base . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/one-api-lab/') === 0) {
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
|
||||||
|
|
||||||
return $scheme . '://' . $host . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strpos($url, '/tools/') === 0 || strpos($url, '/index.php/') === 0) {
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
|
||||||
|
|
||||||
return $scheme . '://' . $host . '/one-api-lab' . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->birt_base . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_order_id_by_payment($payment_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT F_PaymentT_OrderHeaderID
|
|
||||||
FROM f_payment
|
|
||||||
WHERE F_PaymentID = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$payment_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return intval($row['F_PaymentT_OrderHeaderID'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_payment_id_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT F_PaymentID
|
|
||||||
FROM f_payment
|
|
||||||
WHERE F_PaymentT_OrderHeaderID = ?
|
|
||||||
ORDER BY F_PaymentID DESC
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return intval($row['F_PaymentID'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_by_cache($cache_id)
|
|
||||||
{
|
|
||||||
if (!$cache_id) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT ppc_name
|
|
||||||
FROM patient_print_cache
|
|
||||||
WHERE ppc_id = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$cache_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($row['ppc_name'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT ppc_name
|
|
||||||
FROM patient_print_cache
|
|
||||||
WHERE ppc_order_id = ?
|
|
||||||
ORDER BY ppc_id DESC
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($row['ppc_name'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_patient_name_from_enc_by_order($order_id)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT M_PatientName_enc
|
|
||||||
FROM t_orderheader
|
|
||||||
JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID
|
|
||||||
WHERE T_OrderHeaderID = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$order_id]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
return trim($this->ibl_encryptor->decrypt($row['M_PatientName_enc'] ?? '') ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _resolve_report_username()
|
|
||||||
{
|
|
||||||
if (!empty($this->sys_user['M_StaffName'])) {
|
|
||||||
return trim($this->sys_user['M_StaffName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($this->sys_user['M_UserUsername'])) {
|
|
||||||
return trim($this->sys_user['M_UserUsername']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($this->sys_user['userName'])) {
|
|
||||||
return trim($this->sys_user['userName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ADMIN';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _build_birt_url_by_code($report_code, $order_id, $payment_id, $patient_name)
|
|
||||||
{
|
|
||||||
$row = $this->db_onedev->query(
|
|
||||||
"SELECT Print_TransactionUrl
|
|
||||||
FROM print_transaction
|
|
||||||
WHERE Print_TransactionCode = ?
|
|
||||||
LIMIT 1",
|
|
||||||
[$report_code]
|
|
||||||
)->row_array();
|
|
||||||
|
|
||||||
if (!$row) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url_template = $this->_apply_report_template_hotfix($report_code, $row['Print_TransactionUrl']);
|
|
||||||
$username = $this->_resolve_report_username();
|
|
||||||
$tm = round(microtime(true) * 1000);
|
|
||||||
$resolved_payment_id = $payment_id > 0 ? $payment_id : $this->_resolve_payment_id_by_order($order_id);
|
|
||||||
$is_internal_app_url = $this->_is_internal_app_url($url_template);
|
|
||||||
|
|
||||||
$replacements = [
|
|
||||||
'PUsername' => $this->_format_report_string_param($username, $is_internal_app_url),
|
|
||||||
'PT_OrderHeaderID' => $order_id,
|
|
||||||
'PPaymentID' => $resolved_payment_id,
|
|
||||||
'PAn' => $this->_format_report_string_param($patient_name, $is_internal_app_url),
|
|
||||||
'TS' => $tm,
|
|
||||||
];
|
|
||||||
|
|
||||||
$url = $url_template;
|
|
||||||
foreach ($replacements as $placeholder => $value) {
|
|
||||||
if ($value === null) {
|
|
||||||
$value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = str_replace($placeholder, $value, $url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _apply_report_template_hotfix($report_code, $url_template)
|
|
||||||
{
|
|
||||||
$print_report_hotfix = [
|
|
||||||
'LAB-RESULT-P-01' => [
|
|
||||||
'from' => 'rpt_test.rptdesign',
|
|
||||||
'to' => 'rpt_test_bkp020626.rptdesign',
|
|
||||||
],
|
|
||||||
'MIKROO-RESULT-P-01' => [
|
|
||||||
'from' => 'rpt_test.rptdesign',
|
|
||||||
'to' => 'rpt_test_bkp020626.rptdesign',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!isset($print_report_hotfix[$report_code])) {
|
|
||||||
return $url_template;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hotfix = $print_report_hotfix[$report_code];
|
|
||||||
|
|
||||||
$resolved_url = str_replace($hotfix['from'], $hotfix['to'], $url_template);
|
|
||||||
|
|
||||||
if (strpos($resolved_url, 'username=') === false) {
|
|
||||||
$resolved_url .= (strpos($resolved_url, '?') === false ? '?' : '&') . 'username=PUsername';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolved_url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /tools/birt_proxy/header_json?PID=<order_id>
|
|
||||||
// Hanya bisa diakses dari localhost (127.0.0.1) — dipanggil oleh BIRT scripted dataset
|
|
||||||
// Return JSON semua kolom sp_rpt_hasil_header, PII sudah di-decrypt
|
|
||||||
public function header_json()
|
|
||||||
{
|
|
||||||
$order_id = intval($this->input->get('PID') ?? 0);
|
|
||||||
if ($order_id <= 0) {
|
|
||||||
echo json_encode(['error' => 'PID required']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->db_onedev->query("
|
|
||||||
SELECT
|
|
||||||
DATE_FORMAT(T_OrderHeaderDate, '%d-%m-%Y') AS T_OrderHeaderDate,
|
|
||||||
T_OrderHeaderLabNumber,
|
|
||||||
M_TitleName,
|
|
||||||
M_PatientName,
|
|
||||||
M_PatientName_enc,
|
|
||||||
m_sexname AS Gender,
|
|
||||||
M_PatientNoReg,
|
|
||||||
M_PatientDOB,
|
|
||||||
M_PatientDOB_enc,
|
|
||||||
T_OrderHeaderM_PatientAge,
|
|
||||||
M_CompanyName AS CorporateName,
|
|
||||||
M_PatientHp,
|
|
||||||
M_PatientHP_enc,
|
|
||||||
M_PatientEmail,
|
|
||||||
M_PatientEmail_enc,
|
|
||||||
'' AS M_PatientAddressCity,
|
|
||||||
'' AS M_PatientAddressState,
|
|
||||||
M_CompanyName AS CorporateAddress,
|
|
||||||
M_CompanyEmail AS CorporateEmail,
|
|
||||||
M_CompanyPhone AS CorporatePhone,
|
|
||||||
M_CompanyAddressCity AS CorporateAddressCity,
|
|
||||||
'' AS CorporateAddressState,
|
|
||||||
TRIM(CONCAT(IFNULL(pj.M_DoctorPrefix,''),' ',IFNULL(pj.M_DoctorPrefix2,''),' ',IFNULL(pj.M_DoctorName,''),' ',IFNULL(pj.M_DoctorSufix,''),' ',IFNULL(pj.M_DoctorSufix2,''))) AS M_DoctorName,
|
|
||||||
TRIM(CONCAT(IFNULL(pjj.M_DoctorPrefix,''),' ',IFNULL(pjj.M_DoctorPrefix2,''),' ',IFNULL(pjj.M_DoctorName,''),' ',IFNULL(pjj.M_DoctorSufix,''),' ',IFNULL(pjj.M_DoctorSufix2,''))) AS M_DoctorName2,
|
|
||||||
M_PatientID,
|
|
||||||
M_PatientNIP, M_PatientJob, M_PatientPosisi, M_PatientDivisi, M_PatientLocation,
|
|
||||||
CONCAT(IFNULL(M_PatientDepartement,''),' - ',IFNULL(M_PatientNIP,'')) AS M_PatientDepartement
|
|
||||||
FROM t_orderheader
|
|
||||||
LEFT JOIN m_patient ON T_OrderHeaderM_PatientID = M_PatientID AND M_PatientIsActive = 'Y'
|
|
||||||
LEFT JOIN m_sex ON M_PatientM_SexID = M_SexID
|
|
||||||
LEFT JOIN m_title ON M_PatientM_TitleID = M_TitleID AND M_TitleIsActive = 'Y'
|
|
||||||
JOIN m_company ON T_OrderHeaderM_CompanyID = M_CompanyID AND M_CompanyIsActive = 'Y'
|
|
||||||
LEFT JOIN m_doctor pjj ON T_OrderHeaderPj2M_DoctorID = pjj.M_DoctorID AND pjj.M_DoctorIsActive = 'Y'
|
|
||||||
LEFT JOIN m_doctor pj ON T_OrderHeaderPjM_DoctorID = pj.M_DoctorID AND pj.M_DoctorIsActive = 'Y'
|
|
||||||
WHERE T_OrderHeaderID = ? AND T_OrderHeaderIsActive = 'Y'
|
|
||||||
", [$order_id])->row_array();
|
|
||||||
|
|
||||||
if (!$row) {
|
|
||||||
echo json_encode(['error' => 'order not found']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$enc = $this->ibl_encryptor;
|
|
||||||
$name = $enc->decrypt($row['M_PatientName_enc'] ?? '') ?: ($row['M_PatientName'] ?? '');
|
|
||||||
$dob = $enc->decrypt($row['M_PatientDOB_enc'] ?? '') ?: date('d-m-Y', strtotime($row['M_PatientDOB'] ?? 'now'));
|
|
||||||
$hp = $enc->decrypt($row['M_PatientHP_enc'] ?? '') ?: ($row['M_PatientHp'] ?? '');
|
|
||||||
$email= $enc->decrypt($row['M_PatientEmail_enc']?? '') ?: ($row['M_PatientEmail'] ?? '');
|
|
||||||
|
|
||||||
$addr_row = $this->db_onedev->query("
|
|
||||||
SELECT CONCAT(
|
|
||||||
IFNULL(M_PatientAddressDescription,''),' ',
|
|
||||||
IFNULL((SELECT regional_nm FROM regional WHERE regional_cd = NULLIF(TRIM(M_PatientAddressRegionalCd),'') LIMIT 1),'')
|
|
||||||
) AS addr,
|
|
||||||
M_PatientAddressDescription_enc
|
|
||||||
FROM m_patientaddress
|
|
||||||
WHERE M_PatientAddressM_PatientID = ? AND M_PatientAddressIsActive = 'Y'
|
|
||||||
ORDER BY M_PatientAddressID LIMIT 1
|
|
||||||
", [$row['M_PatientID']])->row_array();
|
|
||||||
|
|
||||||
$address = '';
|
|
||||||
if ($addr_row) {
|
|
||||||
$address = $enc->decrypt($addr_row['M_PatientAddressDescription_enc'] ?? '') ?: trim($addr_row['addr'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$umur = $dob . ' / ' . ($row['T_OrderHeaderM_PatientAge'] ?? '');
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'T_OrderHeaderDate' => $row['T_OrderHeaderDate'] ?? '',
|
|
||||||
'T_OrderHeaderLabNumber' => $row['T_OrderHeaderLabNumber'] ?? '',
|
|
||||||
'M_PatientName' => trim(($row['M_TitleName'] ?? '') . '. ' . $name),
|
|
||||||
'Gender' => $row['Gender'] ?? '',
|
|
||||||
'M_PatientNoReg' => $row['M_PatientNoReg'] ?? '',
|
|
||||||
'M_PatientDOB' => $dob,
|
|
||||||
'T_OrderHeaderM_PatientAge' => $row['T_OrderHeaderM_PatientAge'] ?? '',
|
|
||||||
'CorporateName' => $row['CorporateName'] ?? '',
|
|
||||||
'M_PatientAddress' => $address,
|
|
||||||
'M_PatientHp' => $hp,
|
|
||||||
'M_PatientEmail' => $email,
|
|
||||||
'M_PatientAddressCity' => '',
|
|
||||||
'M_PatientAddressState' => '',
|
|
||||||
'CorporateAddress' => $row['CorporateAddress'] ?? '',
|
|
||||||
'CorporateEmail' => $row['CorporateEmail'] ?? '',
|
|
||||||
'CorporatePhone' => $row['CorporatePhone'] ?? '',
|
|
||||||
'CorporateAddressCity' => $row['CorporateAddressCity'] ?? '',
|
|
||||||
'CorporateAddressState' => '',
|
|
||||||
'M_DoctorName' => $row['M_DoctorName'] ?? '',
|
|
||||||
'M_DoctorName2' => $row['M_DoctorName2'] ?? '',
|
|
||||||
'Umur' => $umur,
|
|
||||||
'M_PatientNIP' => $row['M_PatientNIP'] ?? '',
|
|
||||||
'M_PatientJob' => $row['M_PatientJob'] ?? '',
|
|
||||||
'M_PatientPosisi' => $row['M_PatientPosisi'] ?? '',
|
|
||||||
'M_PatientDivisi' => $row['M_PatientDivisi'] ?? '',
|
|
||||||
'M_PatientLocation' => $row['M_PatientLocation'] ?? '',
|
|
||||||
'M_PatientDepartement' => $row['M_PatientDepartement'] ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode($data);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _is_internal_app_url($url)
|
|
||||||
{
|
|
||||||
$url = (string) $url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
strpos($url, '/one-api-lab/') === 0 ||
|
|
||||||
strpos($url, '/tools/') === 0 ||
|
|
||||||
strpos($url, '/index.php/') === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _format_report_string_param($value, $is_internal_app_url = false)
|
|
||||||
{
|
|
||||||
$value = (string) $value;
|
|
||||||
|
|
||||||
if ($is_internal_app_url) {
|
|
||||||
return rawurlencode($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawurlencode("'" . $value . "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,891 @@
|
|||||||
|
# PRD: IBL Sales Dashboard for Marketing Manager and Owner
|
||||||
|
|
||||||
|
**Date:** 2026-06-22
|
||||||
|
**Scope:** Design only. No code changes yet.
|
||||||
|
**Project:** `one-api-lab`
|
||||||
|
**Primary source database:** `one_lab`
|
||||||
|
**Primary ETL target database:** `one_lab_etl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Saat ini data order, pembayaran, company, MOU, omzet type, dan grouping test masih tersebar di banyak tabel operasional. Ini menyulitkan dua audiens utama:
|
||||||
|
|
||||||
|
1. Manager marketing yang butuh melihat pertumbuhan omzet, kontribusi company, komposisi layanan, dan peluang follow-up.
|
||||||
|
2. Pemilik perusahaan yang butuh ringkasan revenue, cash-in, piutang, kolektibilitas, dan tren bisnis secara cepat.
|
||||||
|
|
||||||
|
Query langsung ke tabel transaksi operasional juga berisiko:
|
||||||
|
|
||||||
|
- definisi angka bisa beda-beda antar report,
|
||||||
|
- agregasi menjadi berat untuk dashboard,
|
||||||
|
- filter lintas `company`, `company type group`, `omzet type`, `lunas/tagihan`, dan `nat_subgroup` sulit distandarkan,
|
||||||
|
- level order dan level item test tercampur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Menyediakan satu sumber data dashboard yang konsisten untuk owner dan manager marketing.
|
||||||
|
2. Mendukung filter:
|
||||||
|
- tanggal,
|
||||||
|
- company,
|
||||||
|
- company type group,
|
||||||
|
- omzet type,
|
||||||
|
- status lunas,
|
||||||
|
- status tagihan/outstanding,
|
||||||
|
- nat subgroup.
|
||||||
|
3. Memisahkan metrik level order dan level test detail agar angka tidak double count.
|
||||||
|
4. Menyediakan API PHP yang ringan untuk summary, chart, ranking, dan detail table.
|
||||||
|
5. Menjaga definisi bisnis agar konsisten dengan pola join report existing di repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non Goals
|
||||||
|
|
||||||
|
1. Belum mencakup prediksi sales atau forecasting.
|
||||||
|
2. Belum mencakup target marketing per staff.
|
||||||
|
3. Belum mencakup mutasi invoice, retur, atau akuntansi GL penuh.
|
||||||
|
4. Belum mencakup writeback atau input manual dari dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Users
|
||||||
|
|
||||||
|
### A. Marketing Manager
|
||||||
|
|
||||||
|
Butuh membaca:
|
||||||
|
|
||||||
|
- omzet per periode,
|
||||||
|
- top company,
|
||||||
|
- kontribusi per `omzet type`,
|
||||||
|
- kontribusi per `nat_subgroup`,
|
||||||
|
- company mana yang turun,
|
||||||
|
- company mana yang outstanding-nya tinggi.
|
||||||
|
|
||||||
|
### B. Owner
|
||||||
|
|
||||||
|
Butuh membaca:
|
||||||
|
|
||||||
|
- gross sales,
|
||||||
|
- cash-in,
|
||||||
|
- outstanding,
|
||||||
|
- rasio collection,
|
||||||
|
- distribusi revenue,
|
||||||
|
- aging tagihan,
|
||||||
|
- konsentrasi revenue per customer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Tables
|
||||||
|
|
||||||
|
Tabel utama yang sudah diverifikasi:
|
||||||
|
|
||||||
|
- `t_orderheader`
|
||||||
|
- `t_orderdetailorder`
|
||||||
|
- `t_orderdetail`
|
||||||
|
- `t_test`
|
||||||
|
- `nat_subgroup`
|
||||||
|
- `m_company`
|
||||||
|
- `m_companytypegroup`
|
||||||
|
- `m_companytype`
|
||||||
|
- `m_mou`
|
||||||
|
- `m_omzettype`
|
||||||
|
- `f_payment`
|
||||||
|
- `f_payment_orderheader`
|
||||||
|
|
||||||
|
### Relasi Utama
|
||||||
|
|
||||||
|
#### Relasi order dan company
|
||||||
|
|
||||||
|
- `t_orderheader.T_OrderHeaderM_CompanyID -> m_company.M_CompanyID`
|
||||||
|
- `m_company.M_CompanyM_CompanyGroupTypeID -> m_companytypegroup.M_CompanyTypeGroupID`
|
||||||
|
- `m_company.M_CompanyM_CompanyTypeID -> m_companytype.M_CompanyTypeID`
|
||||||
|
|
||||||
|
#### Relasi order dan MOU / omzet
|
||||||
|
|
||||||
|
- `t_orderheader.T_OrderHeaderM_MouID -> m_mou.M_MouID`
|
||||||
|
- `m_mou.M_MouM_OmzetTypeID -> m_omzettype.M_OmzetTypeID`
|
||||||
|
|
||||||
|
#### Relasi order dan payment
|
||||||
|
|
||||||
|
- `f_payment.F_PaymentT_OrderHeaderID -> t_orderheader.T_OrderHeaderID`
|
||||||
|
- `f_payment_orderheader.F_Payment_OrderHeaderID -> t_orderheader.T_OrderHeaderID`
|
||||||
|
|
||||||
|
#### Relasi detail dan subgroup
|
||||||
|
|
||||||
|
- `t_orderdetail.T_OrderDetailT_TestID -> t_test.T_TestID`
|
||||||
|
- `t_test.T_TestNat_SubgroupID -> nat_subgroup.Nat_SubGroupID`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Business Rules
|
||||||
|
|
||||||
|
1. `sales order amount` memakai basis `t_orderheader.T_OrderHeaderTotal`.
|
||||||
|
2. `cash-in amount` memakai basis akumulasi `f_payment.F_PaymentTotal` aktif.
|
||||||
|
3. `lunas` memakai flag utama `f_payment_orderheader.F_Payment_OrderHeaderIsLunas`.
|
||||||
|
4. `outstanding amount` memakai prioritas:
|
||||||
|
- `f_payment_orderheader.F_Payment_OrderHeaderChargeAmount` bila tersedia,
|
||||||
|
- fallback `order_total - payment_total`.
|
||||||
|
5. `nat_subgroup` adalah atribut level test detail, bukan level order header.
|
||||||
|
6. Satu order bisa punya banyak `nat_subgroup`, sehingga filter subgroup harus memakai mart detail atau bridge.
|
||||||
|
7. Hanya data aktif yang dipakai:
|
||||||
|
- `T_OrderHeaderIsActive = 'Y'`
|
||||||
|
- `T_OrderDetailIsActive = 'Y'`
|
||||||
|
- `M_CompanyIsActive = 'Y'`
|
||||||
|
- `M_MouIsActive = 'Y'`
|
||||||
|
- `M_OmzetTypeIsActive = 'Y'`
|
||||||
|
- `Nat_SubGroupIsActive = 'Y'`
|
||||||
|
- `F_PaymentIsActive = 'Y'`
|
||||||
|
8. Semua tabel ETL baru ditempatkan di database `one_lab_etl`, bukan di `one_lab`, agar query dashboard tidak membebani database transaksional.
|
||||||
|
9. Struktur tabel ETL baru tidak memakai foreign key constraint. Relasi dijaga di level query dan index saja.
|
||||||
|
10. Naming tabel dan kolom baru mengikuti gaya schema inti `one_lab`:
|
||||||
|
- nama tabel: lowercase dengan underscore,
|
||||||
|
- nama kolom: prefix nama entitas tabel sendiri,
|
||||||
|
- hindari gaya generik seperti `created_at`, `updated_at`, atau nama kolom tanpa prefix domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Scope
|
||||||
|
|
||||||
|
Dashboard dibagi menjadi 4 blok:
|
||||||
|
|
||||||
|
1. Executive summary
|
||||||
|
2. Sales composition
|
||||||
|
3. Collection and receivable health
|
||||||
|
4. Customer and subgroup drilldown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KPI Definitions
|
||||||
|
|
||||||
|
### Core KPI
|
||||||
|
|
||||||
|
- `total_sales`: total nilai order
|
||||||
|
- `total_paid`: total pembayaran masuk
|
||||||
|
- `total_outstanding`: sisa tagihan
|
||||||
|
- `collection_rate`: `total_paid / total_sales`
|
||||||
|
- `lunas_order_count`: jumlah order lunas
|
||||||
|
- `open_order_count`: jumlah order belum lunas
|
||||||
|
- `active_company_count`: jumlah company yang punya transaksi
|
||||||
|
|
||||||
|
### Breakdown KPI
|
||||||
|
|
||||||
|
- `sales_by_company`
|
||||||
|
- `sales_by_company_type_group`
|
||||||
|
- `sales_by_omzet_type`
|
||||||
|
- `sales_by_nat_subgroup`
|
||||||
|
- `outstanding_by_company`
|
||||||
|
- `outstanding_by_aging_bucket`
|
||||||
|
|
||||||
|
### Trend KPI
|
||||||
|
|
||||||
|
- `daily_sales_trend`
|
||||||
|
- `monthly_sales_trend`
|
||||||
|
- `daily_cashin_trend`
|
||||||
|
- `monthly_cashin_trend`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Views
|
||||||
|
|
||||||
|
### A. Executive Summary
|
||||||
|
|
||||||
|
Cards:
|
||||||
|
|
||||||
|
- total sales
|
||||||
|
- total paid
|
||||||
|
- total outstanding
|
||||||
|
- collection rate
|
||||||
|
- lunas order
|
||||||
|
- active company
|
||||||
|
|
||||||
|
Charts:
|
||||||
|
|
||||||
|
- line chart `sales vs cash-in`
|
||||||
|
- donut `lunas vs belum lunas`
|
||||||
|
- bar chart `outstanding aging bucket`
|
||||||
|
|
||||||
|
### B. Sales Composition
|
||||||
|
|
||||||
|
Charts:
|
||||||
|
|
||||||
|
- bar `sales by omzet type`
|
||||||
|
- bar `sales by company type group`
|
||||||
|
- horizontal bar `top 10 company by sales`
|
||||||
|
- pareto `sales by nat_subgroup`
|
||||||
|
|
||||||
|
### C. Collection Health
|
||||||
|
|
||||||
|
Charts:
|
||||||
|
|
||||||
|
- bar `top outstanding company`
|
||||||
|
- stacked bar `lunas vs belum lunas by omzet type`
|
||||||
|
- aging chart `0-30 / 31-60 / 61-90 / 90+ days`
|
||||||
|
|
||||||
|
### D. Drilldown Tables
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
|
||||||
|
- order summary table
|
||||||
|
- company ranking table
|
||||||
|
- subgroup contribution table
|
||||||
|
- outstanding exception table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter Requirements
|
||||||
|
|
||||||
|
Global filters:
|
||||||
|
|
||||||
|
1. date range
|
||||||
|
2. company
|
||||||
|
3. company type group
|
||||||
|
4. omzet type
|
||||||
|
5. payment status:
|
||||||
|
- all
|
||||||
|
- lunas
|
||||||
|
- belum lunas
|
||||||
|
6. tagihan status:
|
||||||
|
- all
|
||||||
|
- ada outstanding
|
||||||
|
- outstanding nol
|
||||||
|
7. nat subgroup
|
||||||
|
|
||||||
|
### Filter Semantics
|
||||||
|
|
||||||
|
#### Jika filter `nat_subgroup` dipakai
|
||||||
|
|
||||||
|
- KPI summary tetap harus akurat di level order.
|
||||||
|
- Implementasi awal yang aman:
|
||||||
|
- summary berbasis mart detail dengan `COUNT(DISTINCT order_id)` untuk order count,
|
||||||
|
- nominal sales untuk subgroup memakai `detail_total`,
|
||||||
|
- summary order total saat filter subgroup aktif harus diberi label jelas: `sales contribution by subgroup`, bukan `full order revenue`, agar tidak misleading.
|
||||||
|
|
||||||
|
#### Rekomendasi produk
|
||||||
|
|
||||||
|
Pisahkan dua mode saat filter subgroup aktif:
|
||||||
|
|
||||||
|
1. `Order view`
|
||||||
|
- subgroup dipakai sebagai `has subgroup in order`
|
||||||
|
- nilai sales tetap full order total
|
||||||
|
2. `Contribution view`
|
||||||
|
- subgroup dipakai pada level detail
|
||||||
|
- nilai sales memakai `detail_total`
|
||||||
|
|
||||||
|
Untuk phase 1, lebih aman default ke `Contribution view`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Design
|
||||||
|
|
||||||
|
### Naming Convention for New ETL Tables
|
||||||
|
|
||||||
|
Prinsip penamaan:
|
||||||
|
|
||||||
|
- database target: `one_lab_etl`
|
||||||
|
- nama tabel mengikuti pola lowercase underscore
|
||||||
|
- nama kolom mengikuti pola prefix entitas tabel seperti `T_OrderHeader...`, `M_Company...`, `F_Payment...`
|
||||||
|
- tidak memakai foreign key
|
||||||
|
- relasi dioptimalkan dengan `PRIMARY KEY`, `UNIQUE KEY`, dan `INDEX`
|
||||||
|
|
||||||
|
### 1. `sales_dashboard_order`
|
||||||
|
|
||||||
|
Grain: `1 row = 1 orderheader`
|
||||||
|
|
||||||
|
Tujuan:
|
||||||
|
|
||||||
|
- dashboard utama owner,
|
||||||
|
- KPI order-level,
|
||||||
|
- summary payment / receivable.
|
||||||
|
|
||||||
|
#### Proposed Columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SalesDashboardOrderID BIGINT PK AUTO_INCREMENT
|
||||||
|
SalesDashboardOrderT_OrderHeaderID BIGINT NOT NULL
|
||||||
|
SalesDashboardOrderDate DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderDateOnly DATE NOT NULL
|
||||||
|
SalesDashboardOrderT_OrderHeaderLabNumber VARCHAR(25) NULL
|
||||||
|
SalesDashboardOrderT_OrderHeaderStatus VARCHAR(150) NULL
|
||||||
|
SalesDashboardOrderM_CompanyID INT NULL
|
||||||
|
SalesDashboardOrderM_CompanyName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderM_CompanyTypeID INT NULL
|
||||||
|
SalesDashboardOrderM_CompanyTypeName VARCHAR(50) NULL
|
||||||
|
SalesDashboardOrderM_CompanyTypeGroupID INT NULL
|
||||||
|
SalesDashboardOrderM_CompanyTypeGroupName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderM_MouID INT NULL
|
||||||
|
SalesDashboardOrderM_MouName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderM_OmzetTypeID INT NULL
|
||||||
|
SalesDashboardOrderM_OmzetTypeName VARCHAR(25) NULL
|
||||||
|
SalesDashboardOrderTotal DECIMAL(18,2) NOT NULL DEFAULT 0.00
|
||||||
|
SalesDashboardOrderPaymentTotal DECIMAL(18,2) NOT NULL DEFAULT 0.00
|
||||||
|
SalesDashboardOrderOutstandingTotal DECIMAL(18,2) NOT NULL DEFAULT 0.00
|
||||||
|
SalesDashboardOrderIsLunas CHAR(1) NOT NULL DEFAULT 'N'
|
||||||
|
SalesDashboardOrderHasOutstanding CHAR(1) NOT NULL DEFAULT 'N'
|
||||||
|
SalesDashboardOrderLastPaymentDate DATE NULL
|
||||||
|
SalesDashboardOrderAgingDays INT NOT NULL DEFAULT 0
|
||||||
|
SalesDashboardOrderAgingBucket VARCHAR(20) NOT NULL DEFAULT '0-30'
|
||||||
|
SalesDashboardOrderDetailCount INT NOT NULL DEFAULT 0
|
||||||
|
SalesDashboardOrderSourceLastUpdated DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderCreated DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderLastUpdated DATETIME NOT NULL
|
||||||
|
PRIMARY KEY (SalesDashboardOrderID)
|
||||||
|
UNIQUE KEY SalesDashboardOrderT_OrderHeaderID (SalesDashboardOrderT_OrderHeaderID)
|
||||||
|
KEY SalesDashboardOrderDateOnly (SalesDashboardOrderDateOnly)
|
||||||
|
KEY SalesDashboardOrderM_CompanyID (SalesDashboardOrderM_CompanyID)
|
||||||
|
KEY SalesDashboardOrderM_CompanyTypeGroupID (SalesDashboardOrderM_CompanyTypeGroupID)
|
||||||
|
KEY SalesDashboardOrderM_OmzetTypeID (SalesDashboardOrderM_OmzetTypeID)
|
||||||
|
KEY SalesDashboardOrderIsLunas (SalesDashboardOrderIsLunas)
|
||||||
|
KEY SalesDashboardOrderHasOutstanding (SalesDashboardOrderHasOutstanding)
|
||||||
|
KEY SalesDashboardOrderAgingBucket (SalesDashboardOrderAgingBucket)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `sales_dashboard_order_subgroup`
|
||||||
|
|
||||||
|
Grain: `1 row = 1 orderdetail`
|
||||||
|
|
||||||
|
Tujuan:
|
||||||
|
|
||||||
|
- breakdown `nat_subgroup`,
|
||||||
|
- analisis kontribusi layanan,
|
||||||
|
- drilldown marketing.
|
||||||
|
|
||||||
|
#### Proposed Columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SalesDashboardOrderSubGroupID BIGINT PK AUTO_INCREMENT
|
||||||
|
SalesDashboardOrderSubGroupT_OrderDetailID BIGINT NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupT_OrderHeaderID BIGINT NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupDate DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupDateOnly DATE NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupM_CompanyID INT NULL
|
||||||
|
SalesDashboardOrderSubGroupM_CompanyName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderSubGroupM_CompanyTypeGroupID INT NULL
|
||||||
|
SalesDashboardOrderSubGroupM_CompanyTypeGroupName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderSubGroupM_MouID INT NULL
|
||||||
|
SalesDashboardOrderSubGroupM_MouName VARCHAR(100) NULL
|
||||||
|
SalesDashboardOrderSubGroupM_OmzetTypeID INT NULL
|
||||||
|
SalesDashboardOrderSubGroupM_OmzetTypeName VARCHAR(25) NULL
|
||||||
|
SalesDashboardOrderSubGroupT_TestID INT NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupT_TestCode VARCHAR(25) NULL
|
||||||
|
SalesDashboardOrderSubGroupT_TestName VARCHAR(100) NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupNat_SubGroupID INT NULL
|
||||||
|
SalesDashboardOrderSubGroupNat_SubGroupCode VARCHAR(10) NULL
|
||||||
|
SalesDashboardOrderSubGroupNat_SubGroupName VARCHAR(50) NULL
|
||||||
|
SalesDashboardOrderSubGroupPriceTotal DECIMAL(18,2) NOT NULL DEFAULT 0.00
|
||||||
|
SalesDashboardOrderSubGroupNetTotal DECIMAL(18,2) NOT NULL DEFAULT 0.00
|
||||||
|
SalesDashboardOrderSubGroupIsLunas CHAR(1) NOT NULL DEFAULT 'N'
|
||||||
|
SalesDashboardOrderSubGroupSourceLastUpdated DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupCreated DATETIME NOT NULL
|
||||||
|
SalesDashboardOrderSubGroupLastUpdated DATETIME NOT NULL
|
||||||
|
PRIMARY KEY (SalesDashboardOrderSubGroupID)
|
||||||
|
UNIQUE KEY SalesDashboardOrderSubGroupT_OrderDetailID (SalesDashboardOrderSubGroupT_OrderDetailID)
|
||||||
|
KEY SalesDashboardOrderSubGroupT_OrderHeaderID (SalesDashboardOrderSubGroupT_OrderHeaderID)
|
||||||
|
KEY SalesDashboardOrderSubGroupDateOnly (SalesDashboardOrderSubGroupDateOnly)
|
||||||
|
KEY SalesDashboardOrderSubGroupM_CompanyID (SalesDashboardOrderSubGroupM_CompanyID)
|
||||||
|
KEY SalesDashboardOrderSubGroupM_CompanyTypeGroupID (SalesDashboardOrderSubGroupM_CompanyTypeGroupID)
|
||||||
|
KEY SalesDashboardOrderSubGroupM_OmzetTypeID (SalesDashboardOrderSubGroupM_OmzetTypeID)
|
||||||
|
KEY SalesDashboardOrderSubGroupNat_SubGroupID (SalesDashboardOrderSubGroupNat_SubGroupID)
|
||||||
|
KEY SalesDashboardOrderSubGroupIsLunas (SalesDashboardOrderSubGroupIsLunas)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `sales_dashboard_etl_log`
|
||||||
|
|
||||||
|
Tujuan:
|
||||||
|
|
||||||
|
- audit ETL,
|
||||||
|
- troubleshooting refresh,
|
||||||
|
- monitoring last successful sync.
|
||||||
|
|
||||||
|
#### Proposed Columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SalesDashboardEtlLogID BIGINT PK AUTO_INCREMENT
|
||||||
|
SalesDashboardEtlLogJobName VARCHAR(100) NOT NULL
|
||||||
|
SalesDashboardEtlLogStarted DATETIME NOT NULL
|
||||||
|
SalesDashboardEtlLogFinished DATETIME NULL
|
||||||
|
SalesDashboardEtlLogStatus VARCHAR(20) NOT NULL
|
||||||
|
SalesDashboardEtlLogLastWatermark DATETIME NULL
|
||||||
|
SalesDashboardEtlLogRowsInserted INT NOT NULL DEFAULT 0
|
||||||
|
SalesDashboardEtlLogRowsUpdated INT NOT NULL DEFAULT 0
|
||||||
|
SalesDashboardEtlLogRowsDeleted INT NOT NULL DEFAULT 0
|
||||||
|
SalesDashboardEtlLogErrorMessage TEXT NULL
|
||||||
|
SalesDashboardEtlLogCreated DATETIME NOT NULL
|
||||||
|
PRIMARY KEY (SalesDashboardEtlLogID)
|
||||||
|
KEY SalesDashboardEtlLogJobName (SalesDashboardEtlLogJobName)
|
||||||
|
KEY SalesDashboardEtlLogStatus (SalesDashboardEtlLogStatus)
|
||||||
|
KEY SalesDashboardEtlLogStarted (SalesDashboardEtlLogStarted)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ETL Strategy
|
||||||
|
|
||||||
|
### Recommended Approach
|
||||||
|
|
||||||
|
Gunakan ETL incremental ke tabel fisik di database `one_lab_etl`, bukan query live langsung dari tabel operasional `one_lab` untuk semua chart.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
1. Query dashboard akan jauh lebih cepat.
|
||||||
|
2. Definisi bisnis menjadi terkunci dan konsisten.
|
||||||
|
3. Filter multi-dimensi lebih gampang.
|
||||||
|
4. Aman untuk kebutuhan chart owner yang sering membuka data agregat.
|
||||||
|
|
||||||
|
### Load Frequency
|
||||||
|
|
||||||
|
Phase 1:
|
||||||
|
|
||||||
|
- scheduled refresh tiap `30 menit`
|
||||||
|
- plus endpoint manual refresh untuk dev/admin
|
||||||
|
|
||||||
|
Phase 2 opsional:
|
||||||
|
|
||||||
|
- refresh per `5 menit` atau near real-time
|
||||||
|
|
||||||
|
### Watermark Incremental
|
||||||
|
|
||||||
|
Gunakan `MAX(last_updated)` dari sumber:
|
||||||
|
|
||||||
|
- `t_orderheader.T_OrderHeaderLastUpdated`
|
||||||
|
- `t_orderdetail.T_OrderDetailLastUpdated`
|
||||||
|
- `f_payment.F_PaymentLastUpdated`
|
||||||
|
|
||||||
|
Untuk order-level ETL, satu order harus di-rebuild jika salah satu dari tiga sumber berubah.
|
||||||
|
|
||||||
|
### ETL Steps
|
||||||
|
|
||||||
|
#### Job 1: Build `one_lab_etl.sales_dashboard_order`
|
||||||
|
|
||||||
|
1. Ambil order aktif yang berubah sejak watermark.
|
||||||
|
2. Join:
|
||||||
|
- `m_company`
|
||||||
|
- `m_companytype`
|
||||||
|
- `m_companytypegroup`
|
||||||
|
- `m_mou`
|
||||||
|
- `m_omzettype`
|
||||||
|
3. Aggregate payment aktif per order dari `f_payment`.
|
||||||
|
4. Ambil status lunas dari `f_payment_orderheader`.
|
||||||
|
5. Hitung:
|
||||||
|
- `payment_amount`
|
||||||
|
- `outstanding_amount`
|
||||||
|
- `has_outstanding`
|
||||||
|
- `aging_days`
|
||||||
|
- `aging_bucket`
|
||||||
|
6. Upsert ke `one_lab_etl.sales_dashboard_order`.
|
||||||
|
|
||||||
|
#### Job 2: Build `one_lab_etl.sales_dashboard_order_subgroup`
|
||||||
|
|
||||||
|
1. Ambil detail aktif yang berubah sejak watermark, atau detail milik order yang berubah.
|
||||||
|
2. Join:
|
||||||
|
- `t_test`
|
||||||
|
- `nat_subgroup`
|
||||||
|
- `m_company`
|
||||||
|
- `m_companytypegroup`
|
||||||
|
- `m_mou`
|
||||||
|
- `m_omzettype`
|
||||||
|
3. Tempel status lunas dari hasil order fact.
|
||||||
|
4. Upsert ke `one_lab_etl.sales_dashboard_order_subgroup`.
|
||||||
|
|
||||||
|
#### Job 3: ETL log
|
||||||
|
|
||||||
|
1. Tulis status awal job.
|
||||||
|
2. Simpan jumlah row insert/update.
|
||||||
|
3. Simpan watermark akhir.
|
||||||
|
4. Simpan error jika gagal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### Controller Proposal
|
||||||
|
|
||||||
|
File baru:
|
||||||
|
|
||||||
|
`application/controllers/dashboard/Sales.php`
|
||||||
|
|
||||||
|
### Endpoint Proposal
|
||||||
|
|
||||||
|
#### 1. Summary KPI
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/summary`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- cards executive summary
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- `date_start`
|
||||||
|
- `date_end`
|
||||||
|
- `company_id`
|
||||||
|
- `company_type_group_id`
|
||||||
|
- `omzet_type_id`
|
||||||
|
- `is_lunas`
|
||||||
|
- `has_outstanding`
|
||||||
|
- `nat_subgroup_id`
|
||||||
|
- `view_mode` = `order|contribution`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
|
"total_sales": 0,
|
||||||
|
"total_paid": 0,
|
||||||
|
"total_outstanding": 0,
|
||||||
|
"collection_rate": 0,
|
||||||
|
"lunas_order_count": 0,
|
||||||
|
"open_order_count": 0,
|
||||||
|
"active_company_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Sales Trend
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/trend`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- line chart sales vs cash-in
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- semua filter global
|
||||||
|
- `granularity` = `day|month`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"period": "2026-06-01",
|
||||||
|
"sales_amount": 0,
|
||||||
|
"paid_amount": 0,
|
||||||
|
"outstanding_amount": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Breakdown
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/breakdown`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- reusable endpoint untuk donut/bar by dimension
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- semua filter global
|
||||||
|
- `dimension` = `company|company_type_group|omzet_type|nat_subgroup|aging_bucket|lunas_status`
|
||||||
|
- `metric` = `sales|paid|outstanding|order_count`
|
||||||
|
- `limit`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"dimension_id": "1",
|
||||||
|
"dimension_name": "Kimia Klinik",
|
||||||
|
"metric_value": 0,
|
||||||
|
"order_count": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Company Ranking
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/company-ranking`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- top/bottom company
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- semua filter global
|
||||||
|
- `sort_by` = `sales|paid|outstanding|collection_rate`
|
||||||
|
- `sort_dir` = `asc|desc`
|
||||||
|
- `limit`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"company_id": 0,
|
||||||
|
"company_name": "",
|
||||||
|
"company_type_group_name": "",
|
||||||
|
"omzet_type_name": "",
|
||||||
|
"sales_amount": 0,
|
||||||
|
"paid_amount": 0,
|
||||||
|
"outstanding_amount": 0,
|
||||||
|
"collection_rate": 0,
|
||||||
|
"order_count": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Order Detail Table
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/orders`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- detail table order-level
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- semua filter global
|
||||||
|
- `page`
|
||||||
|
- `page_size`
|
||||||
|
- `sort_by`
|
||||||
|
- `sort_dir`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"order_id": 0,
|
||||||
|
"order_date": "",
|
||||||
|
"lab_number": "",
|
||||||
|
"company_name": "",
|
||||||
|
"company_type_group_name": "",
|
||||||
|
"mou_name": "",
|
||||||
|
"omzet_type_name": "",
|
||||||
|
"gross_sales_amount": 0,
|
||||||
|
"payment_amount": 0,
|
||||||
|
"outstanding_amount": 0,
|
||||||
|
"is_lunas": "N",
|
||||||
|
"aging_bucket": "0-30"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_rows": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Subgroup Contribution Table
|
||||||
|
|
||||||
|
**Route:** `GET /dashboard/sales/subgroup-contribution`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- detail analisis subgroup untuk marketing
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- semua filter global
|
||||||
|
- `page`
|
||||||
|
- `page_size`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"nat_subgroup_id": 0,
|
||||||
|
"nat_subgroup_name": "",
|
||||||
|
"sales_amount": 0,
|
||||||
|
"detail_count": 0,
|
||||||
|
"company_count": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. ETL Refresh
|
||||||
|
|
||||||
|
**Route:** `POST /dashboard/sales/refresh`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- manual trigger ETL oleh admin/dev
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"data": {
|
||||||
|
"job_name": "sales_dashboard_refresh",
|
||||||
|
"message": "Refresh started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHP Function Proposal
|
||||||
|
|
||||||
|
Di controller `dashboard/Sales.php`, fungsi yang disarankan:
|
||||||
|
|
||||||
|
- `summary()`
|
||||||
|
- `trend()`
|
||||||
|
- `breakdown()`
|
||||||
|
- `company_ranking()`
|
||||||
|
- `orders()`
|
||||||
|
- `subgroup_contribution()`
|
||||||
|
- `refresh()`
|
||||||
|
|
||||||
|
Di library/service baru, misalnya `application/libraries/SalesDashboardService.php`:
|
||||||
|
|
||||||
|
- `buildFilter(array $params): array`
|
||||||
|
- `getSummary(array $filters): array`
|
||||||
|
- `getTrend(array $filters, string $granularity): array`
|
||||||
|
- `getBreakdown(array $filters, string $dimension, string $metric): array`
|
||||||
|
- `getCompanyRanking(array $filters, array $paging): array`
|
||||||
|
- `getOrders(array $filters, array $paging): array`
|
||||||
|
- `getSubgroupContribution(array $filters, array $paging): array`
|
||||||
|
- `runRefresh(?string $forcedFrom = null): array`
|
||||||
|
- `refreshOrderFact(?string $watermark = null): array`
|
||||||
|
- `refreshSubgroupFact(?string $watermark = null): array`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQL Semantics by Use Case
|
||||||
|
|
||||||
|
### Summary without subgroup filter
|
||||||
|
|
||||||
|
Source utama:
|
||||||
|
|
||||||
|
- `one_lab_etl.sales_dashboard_order`
|
||||||
|
|
||||||
|
### Summary with subgroup filter
|
||||||
|
|
||||||
|
Source utama:
|
||||||
|
|
||||||
|
- `one_lab_etl.sales_dashboard_order_subgroup`
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
|
||||||
|
- bila `view_mode = contribution`, agregasi nominal dari detail fact
|
||||||
|
- bila `view_mode = order`, filter order yang punya subgroup terkait lalu join ke order fact
|
||||||
|
|
||||||
|
### Trend sales vs payment
|
||||||
|
|
||||||
|
Gunakan:
|
||||||
|
|
||||||
|
- sales berdasarkan `order_date_key`
|
||||||
|
- payment berdasarkan `payment_date_last` untuk phase 1 sederhana
|
||||||
|
|
||||||
|
Catatan:
|
||||||
|
|
||||||
|
Jika owner butuh cash-in by actual payment day yang akurat penuh, phase berikutnya perlu mart payment harian terpisah.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Future Extension
|
||||||
|
|
||||||
|
### Optional Table: `sales_dashboard_payment`
|
||||||
|
|
||||||
|
Grain: `1 row = 1 payment`
|
||||||
|
|
||||||
|
Dipakai bila nanti butuh:
|
||||||
|
|
||||||
|
- cash-in trend murni berdasarkan tanggal bayar,
|
||||||
|
- payment method analysis,
|
||||||
|
- rekonsiliasi koleksi lebih detail.
|
||||||
|
|
||||||
|
Untuk phase 1, tabel ini opsional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UAT Checklist
|
||||||
|
|
||||||
|
1. Summary total sales sesuai order aktif pada periode yang sama.
|
||||||
|
2. Total paid sesuai akumulasi `f_payment` aktif.
|
||||||
|
3. Lunas count sesuai flag `f_payment_orderheader`.
|
||||||
|
4. Outstanding count dan nominal sesuai expected manual sample.
|
||||||
|
5. Filter `company` bekerja konsisten di summary, chart, dan table.
|
||||||
|
6. Filter `company type group` bekerja konsisten.
|
||||||
|
7. Filter `omzet type` bekerja konsisten.
|
||||||
|
8. Filter `nat_subgroup` menampilkan kontribusi subgroup yang benar.
|
||||||
|
9. Top company dan subgroup ranking konsisten dengan sample query manual.
|
||||||
|
10. Refresh ETL tidak membuat duplicate row pada mart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
1. Definisi `lunas` dan `outstanding` bisa berbeda antara report lama dan kebutuhan dashboard baru.
|
||||||
|
2. Jika filter subgroup dipaksa memakai full order value tanpa mode khusus, angka bisa misleading.
|
||||||
|
3. Data payment historis bisa perlu mart terpisah bila owner ingin cashflow by actual payment day.
|
||||||
|
4. Jika update transaksi lama sering terjadi, ETL incremental harus cukup hati-hati memilih order yang direbuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions for Approval
|
||||||
|
|
||||||
|
1. Saat filter `nat_subgroup` aktif, apakah default yang diinginkan:
|
||||||
|
- `contribution view` berbasis detail amount, atau
|
||||||
|
- `order view` berbasis full order amount?
|
||||||
|
2. Untuk KPI owner, apakah `sales` mau dianggap:
|
||||||
|
- `gross order total`, atau
|
||||||
|
- `net detail total` dari item setelah diskon?
|
||||||
|
3. Apakah cash-in trend perlu by `actual payment date` dari awal phase 1?
|
||||||
|
4. Apakah perlu dimensi tambahan `marketing/staff` karena `m_company` punya `M_CompanyM_StaffID`?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Phase Plan
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- buat tabel mart:
|
||||||
|
- `sales_dashboard_order`
|
||||||
|
- `sales_dashboard_order_subgroup`
|
||||||
|
- `sales_dashboard_etl_log`
|
||||||
|
- buat endpoint:
|
||||||
|
- `summary`
|
||||||
|
- `trend`
|
||||||
|
- `breakdown`
|
||||||
|
- `company_ranking`
|
||||||
|
- `orders`
|
||||||
|
- `subgroup_contribution`
|
||||||
|
- `refresh`
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- tambah `sales_dashboard_payment`
|
||||||
|
- tambah chart cash-in by payment date murni
|
||||||
|
- tambah analisis staff marketing
|
||||||
|
- tambah alert outstanding company besar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Saya rekomendasikan lanjut implementasi dengan pendekatan:
|
||||||
|
|
||||||
|
1. mart fisik untuk order-level dan subgroup-level,
|
||||||
|
2. API PHP berbasis mart, bukan query operasional mentah,
|
||||||
|
3. default subgroup mode = `contribution view`,
|
||||||
|
4. phase 1 fokus owner + marketing summary yang cepat dan stabil,
|
||||||
|
5. payment mart detail dijadikan phase 2 bila dibutuhkan owner untuk cashflow yang lebih presisi.
|
||||||
Reference in New Issue
Block a user