Initial commit

This commit is contained in:
Porawit Dongwang
2026-09-16 23:20:08 +07:00
commit 0041668dbb
32577 changed files with 3687927 additions and 0 deletions
@@ -0,0 +1,48 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
/**
* Class AuditLogger
* High-Level Audit Logging Helper
*
* @package App\Services
*/
class AuditLogger
{
public static function logLogin(int $userId, string $role): void
{
AuditLog::record('LOGIN_SUCCESS', 'users', $userId, null, ['role' => $role, 'timestamp' => date('Y-m-d H:i:s')], $userId);
}
public static function logLoginFailed(string $nationalId, string $reason): void
{
AuditLog::record('LOGIN_FAILED', 'users', null, null, ['national_id' => $nationalId, 'reason' => $reason]);
}
public static function logQueueCreate(int $queueId, string $queueNo, string $status): void
{
AuditLog::record('QUEUE_CREATE', 'queues', $queueId, null, ['queue_no' => $queueNo, 'status' => $status]);
}
public static function logQueueUpdate(int $queueId, string $queueNo, string $oldStatus, string $newStatus): void
{
AuditLog::record('QUEUE_UPDATE', 'queues', $queueId, ['status' => $oldStatus], ['queue_no' => $queueNo, 'status' => $newStatus]);
}
public static function logSoapNote(int $noteId, int $queueId, int $patientId): void
{
AuditLog::record('SOAP_CREATE_SIGN', 'soap_notes', $noteId, null, ['queue_id' => $queueId, 'patient_id' => $patientId]);
}
public static function logPayment(int $paymentId, string $receiptNo, float $netAmount): void
{
AuditLog::record('PAYMENT_CHECKOUT', 'payments', $paymentId, null, ['receipt_no' => $receiptNo, 'net_amount' => $netAmount]);
}
public static function logSecurityAlert(string $alertType, array $details): void
{
AuditLog::record('SECURITY_ALERT', 'system', null, null, ['type' => $alertType, 'details' => $details]);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Services;
/**
* Class FhirService
* HL7 FHIR R4 Interoperability Engine (Patient, Encounter, Observation, Procedure)
*
* @package App\Services
*/
class FhirService
{
private string $serverUrl;
private string $facilityId;
public function __construct()
{
$config = require __DIR__ . '/../../config/integration.php';
$fhir = $config['fhir'];
$this->serverUrl = rtrim($fhir['server_url'], '/');
$this->facilityId = $fhir['facility_id'] ?? 'THAI_MED_CENTER';
}
/**
* Build FHIR R4 Patient Resource
*/
public function buildPatientResource(array $patient): array
{
return [
'resourceType' => 'Patient',
'id' => (string)$patient['id'],
'identifier' => [
[
'use' => 'official',
'system' => 'https://www.dopa.go.th/cid',
'value' => $patient['cid'],
],
[
'use' => 'usual',
'system' => "https://his.hospital.local/hn/{$this->facilityId}",
'value' => $patient['hn'],
]
],
'active' => true,
'name' => [
[
'use' => 'official',
'text' => trim(($patient['first_name_th'] ?? '') . ' ' . ($patient['last_name_th'] ?? '')),
'family' => $patient['last_name_th'] ?? '',
'given' => [$patient['first_name_th'] ?? ''],
]
],
'gender' => strtolower($patient['gender'] ?? 'unknown'),
'birthDate' => $patient['birth_date'] ?? null,
'telecom' => [
[
'system' => 'phone',
'value' => $patient['phone'] ?? '',
'use' => 'mobile',
]
],
];
}
/**
* Build FHIR R4 Observation Resource for Pain Score (VAS 0-10)
*/
public function buildPainScoreObservation(int $patientId, int $encounterId, int $painScore, string $type = 'Pre-treatment'): array
{
return [
'resourceType' => 'Observation',
'status' => 'final',
'category' => [
[
'coding' => [
[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'exam',
'display' => 'Exam',
]
]
]
],
'code' => [
'coding' => [
[
'system' => 'http://loinc.org',
'code' => '72514-3',
'display' => 'Pain severity - 0-10 verbal numeric rating [Score] - Reported',
]
],
'text' => "VAS Pain Score ({$type})",
],
'subject' => [
'reference' => "Patient/{$patientId}",
],
'encounter' => [
'reference' => "Encounter/{$encounterId}",
],
'effectiveDateTime' => date('c'),
'valueInteger' => $painScore,
];
}
/**
* Export complete bundle to FHIR Server
*/
public function exportEncounterBundle(array $queue, array $soapNote): array
{
$patientRes = $this->buildPatientResource(['id' => $queue['patient_id'], 'cid' => $queue['cid'] ?? '', 'hn' => $queue['hn'] ?? '', 'first_name_th' => $queue['patient_name'] ?? '']);
$bundle = [
'resourceType' => 'Bundle',
'type' => 'transaction',
'entry' => [
[
'resource' => $patientRes,
'request' => ['method' => 'PUT', 'url' => "Patient/{$queue['patient_id']}"],
]
],
];
if (isset($soapNote['pre_pain_score'])) {
$obsPre = $this->buildPainScoreObservation((int)$queue['patient_id'], (int)$queue['id'], (int)$soapNote['pre_pain_score'], 'Pre-treatment');
$bundle['entry'][] = [
'resource' => $obsPre,
'request' => ['method' => 'POST', 'url' => "Observation"],
];
}
if (isset($soapNote['post_pain_score'])) {
$obsPost = $this->buildPainScoreObservation((int)$queue['patient_id'], (int)$queue['id'], (int)$soapNote['post_pain_score'], 'Post-treatment');
$bundle['entry'][] = [
'resource' => $obsPost,
'request' => ['method' => 'POST', 'url' => "Observation"],
];
}
return $bundle;
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use App\Models\Patient;
/**
* Class HisConnector
* Hospital Information System (HIS / HOSxP) REST API Connector
*
* @package App\Services
*/
class HisConnector
{
private string $baseUrl;
private string $apiKey;
private int $timeout;
private bool $enabled;
public function __construct()
{
$config = require __DIR__ . '/../../config/integration.php';
$his = $config['his'];
$this->baseUrl = rtrim($his['api_base_url'], '/');
$this->apiKey = $his['api_key'];
$this->timeout = $his['timeout_seconds'] ?? 5;
$this->enabled = $his['enabled'] ?? true;
}
/**
* Fetch Patient Demographics from HIS by HN or CID
*/
public function getPatientFromHis(string $query, string $type = 'hn'): ?array
{
if (!$this->enabled) return null;
$endpoint = "{$this->baseUrl}/patients?" . ($type === 'hn' ? "hn={$query}" : "cid={$query}");
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$this->apiKey}",
"Accept: application/json",
],
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && $response) {
$data = json_decode($response, true);
return $data['data'] ?? $data ?? null;
}
// หากเชื่อมต่อ HIS ไม่ได้ในสภาพแวดล้อม Demo ให้สร้าง Fallback Mock Data ส่งกลับทันที
return $this->getMockHisData($query, $type);
}
/**
* Send completed massage treatment result back to HIS EMR
*/
public function sendTreatmentResult(int $patientId, array $soapData): bool
{
if (!$this->enabled) return true;
$patientModel = new Patient();
$patient = $patientModel->find($patientId);
if (!$patient) return false;
$payload = [
'hn' => $patient['hn'],
'cid' => $patient['cid'],
'treatment_date' => date('Y-m-d'),
'dept_code' => 'THAI_MED',
'chief_complaint' => $soapData['subjective_symptoms'] ?? '',
'physical_exam' => $soapData['objective_signs'] ?? '',
'pre_pain_score' => $soapData['pre_pain_score'] ?? null,
'post_pain_score' => $soapData['post_pain_score'] ?? null,
'diag_text' => $soapData['assessment_diag'] ?? 'Thai traditional massage therapy',
];
$ch = curl_init("{$this->baseUrl}/encounters");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$this->apiKey}",
"Content-Type: application/json",
],
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode >= 200 && $httpCode < 300;
}
private function getMockHisData(string $query, string $type): array
{
return [
'hn' => $type === 'hn' ? $query : ('HN-' . rand(100000, 999999)),
'cid' => $type === 'cid' ? $query : '3100000000000',
'first_name_th' => 'ข้อมูลผู้ป่วยจาก',
'last_name_th' => 'ระบบ HIS (Mock)',
'birth_date' => '1980-01-01',
'gender' => 'Male',
'underlying_diseases' => 'ความดันโลหิตสูง (จาก HIS HOSxP)',
'drug_allergies' => 'ไม่มีประวัติแพ้ยา',
'insurance_type' => 'สิทธิบัตรทอง / สปสช.',
];
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Services;
/**
* Class NotificationService
* Enterprise Multi-Channel Notification Gateway (LINE Notify & LINE OA Flex Message)
*
* @package App\Services
*/
class NotificationService
{
private string $lineNotifyToken;
private bool $notifyEnabled;
public function __construct()
{
$config = require __DIR__ . '/../../config/integration.php';
$notif = $config['notification'];
$this->lineNotifyToken = $notif['line_notify']['token'] ?? '';
$this->notifyEnabled = $notif['line_notify']['enabled'] ?? true;
}
/**
* Notify when a new queue is assigned via Smart AI
*/
public function notifyQueueAssigned(array $queueData): bool
{
if (!$this->notifyEnabled || empty($this->lineNotifyToken)) return false;
$msg = "\n🟢 [จัดสรรคิวอัตโนมัติสำเร็จ]\n";
$msg .= "• หมายเลขคิว: {$queueData['queue_no']}\n";
$msg .= "• รหัสหมอนวด: T-{$queueData['assigned_therapist_id']}\n";
$msg .= "• รหัสห้องพัก: R-{$queueData['assigned_room_id']}\n";
$msg .= "• เวลาเริ่มประมาณการ: {$queueData['est_start_time']}\n";
$msg .= "• ข้อความจาก AI: {$queueData['ai_message']}";
return $this->sendLineNotify($msg);
}
/**
* Notify when TV display calls a queue
*/
public function notifyQueueCalled(string $queueNo, string $roomNo, string $patientName): bool
{
if (!$this->notifyEnabled || empty($this->lineNotifyToken)) return false;
$msg = "\n📣 [เรียกคิวเข้ารับบริการ]\n";
$msg .= "• ขอเชิญคิวหมายเลข: {$queueNo}\n";
$msg .= "• ผู้รับบริการ: {$patientName}\n";
$msg .= "• เข้ารับบริการที่ห้อง: {$roomNo}\n";
return $this->sendLineNotify($msg);
}
/**
* Send raw LINE Notify HTTP POST request
*/
private function sendLineNotify(string $message): bool
{
$ch = curl_init("https://notify-api.line.me/api/notify");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['message' => $message]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$this->lineNotifyToken}",
"Content-Type: application/x-www-form-urlencoded",
],
CURLOPT_SSL_VERIFYPEER => false,
]);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 200;
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Services;
use App\Models\Queue;
use App\Models\Room;
use App\Models\Therapist;
/**
* Class SmartQueueEngine
* AI Queue Allocation & Dynamic Time Estimator Engine
*
* @package App\Services
*/
class SmartQueueEngine
{
private Queue $queueModel;
private Therapist $therapistModel;
private Room $roomModel;
public function __construct()
{
$this->queueModel = new Queue();
$this->therapistModel = new Therapist();
$this->roomModel = new Room();
}
/**
* Process walk-in or appointment queue with Smart AI Allocation
*/
public function allocateQueue(array $queueData): array
{
// 1. สร้างคิวและเรียกใช้ Stored Procedure sp_assign_smart_queue
$result = $this->queueModel->createWithSmartAssign($queueData);
// 2. หากจัดสรรสำเร็จ ให้ส่งการแจ้งเตือน (LINE Notify / OA) และสร้างบันทึก Audit
if ($result['assigned_therapist_id']) {
AuditLogger::logQueueCreate($result['queue_id'], $result['queue_no'], 'Assigned via AI');
(new NotificationService())->notifyQueueAssigned($result);
} else {
AuditLogger::logQueueCreate($result['queue_id'], $result['queue_no'], 'Waiting (All busy)');
}
return $result;
}
/**
* Recalculate estimated start and end times for all waiting queues in a branch
*/
public function recalculateEstimations(int $branchId): void
{
$waitingQueues = $this->queueModel->where([
'branch_id' => $branchId,
'queue_date' => date('Y-m-d'),
'status' => 'Waiting',
], "CASE priority WHEN 'Emergency' THEN 1 WHEN 'VIP' THEN 2 ELSE 3 END ASC, checkin_time ASC");
if (empty($waitingQueues)) return;
// หาเวลาสิ้นสุดล่าสุดของคิวที่กำลังทำอยู่
$activeQueues = $this->queueModel->where([
'branch_id' => $branchId,
'queue_date' => date('Y-m-d'),
'status' => 'In_Progress',
], "est_end_time DESC", 1);
$baseTime = !empty($activeQueues) && !empty($activeQueues[0]['est_end_time'])
? strtotime($activeQueues[0]['est_end_time'])
: time();
$bufferMins = 15; // 15 นาทีเตรียมเตียงและทำความสะอาดตามมาตรฐานสปา/คลินิก
foreach ($waitingQueues as $q) {
$estStart = date('Y-m-d H:i:s', $baseTime + ($bufferMins * 60));
// สมมติระยะเวลา 60 นาทีถ้าไม่มี service_time
$duration = 60;
$estEnd = date('Y-m-d H:i:s', strtotime($estStart) + ($duration * 60));
$this->queueModel->update((int)$q['id'], [
'est_start_time' => $estStart,
'est_end_time' => $estEnd,
'updated_at' => date('Y-m-d H:i:s'),
]);
$baseTime = strtotime($estEnd);
}
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
/**
* Class TwoFactorService
* Enterprise 2FA TOTP RFC 6238 Service (Google Authenticator Compatible)
*
* @package App\Services
*/
class TwoFactorService
{
private static array $base32Chars = [
'A'=>0,'B'=>1,'C'=>2,'D'=>3,'E'=>4,'F'=>5,'G'=>6,'H'=>7,
'I'=>8,'J'=>9,'K'=>10,'L'=>11,'M'=>12,'N'=>13,'O'=>14,'P'=>15,
'Q'=>16,'R'=>17,'S'=>18,'T'=>19,'U'=>20,'V'=>21,'W'=>22,'X'=>23,
'Y'=>24,'Z'=>25,'2'=>26,'3'=>27,'4'=>28,'5'=>29,'6'=>30,'7'=>31
];
/**
* Generate 16-character Base32 secret key
*/
public static function generateSecret(): string
{
$chars = array_keys(self::$base32Chars);
$secret = '';
for ($i = 0; $i < 16; $i++) {
$secret .= $chars[random_int(0, 31)];
}
return $secret;
}
/**
* Generate otpauth:// URI for Google Authenticator QR Code
*/
public static function getQrCodeUrl(string $issuer, string $accountName, string $secret): string
{
$encodedIssuer = urlencode($issuer);
$encodedAccount = urlencode($accountName);
return "otpauth://totp/{$encodedIssuer}:{$encodedAccount}?secret={$secret}&issuer={$encodedIssuer}&algorithm=SHA1&digits=6&period=30";
}
/**
* Verify 6-digit TOTP Code against Secret (RFC 6238)
*/
public static function verifyCode(string $secret, string $code, int $window = 1): bool
{
if (strlen($code) !== 6 || !is_numeric($code)) return false;
$timestamp = time();
$timeSlice = (int)floor($timestamp / 30);
for ($i = -$window; $i <= $window; $i++) {
$calculated = self::calculateCode($secret, $timeSlice + $i);
if (hash_equals($calculated, str_pad($code, 6, '0', STR_PAD_LEFT))) {
return true;
}
}
return false;
}
/**
* Calculate 6-digit TOTP for a specific time slice
*/
private static function calculateCode(string $secret, int $timeSlice): string
{
$secretKey = self::base32Decode(strtoupper($secret));
// Pack time slice into 64-bit big-endian binary string
$timeBytes = pack('N2', 0, $timeSlice);
$hash = hash_hmac('sha1', $timeBytes, $secretKey, true);
$offset = ord($hash[19]) & 0xf;
$otp = (
((ord($hash[$offset+0]) & 0x7f) << 24 ) |
((ord($hash[$offset+1]) & 0xff) << 16 ) |
((ord($hash[$offset+2]) & 0xff) << 8 ) |
(ord($hash[$offset+3]) & 0xff)
) % 1000000;
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
}
private static function base32Decode(string $secret): string
{
$secret = strtoupper($secret);
$buffer = 0;
$bitsLeft = 0;
$result = '';
for ($i = 0; $i < strlen($secret); $i++) {
$char = $secret[$i];
if (!isset(self::$base32Chars[$char])) continue;
$buffer <<= 5;
$buffer |= self::$base32Chars[$char];
$bitsLeft += 5;
if ($bitsLeft >= 8) {
$bitsLeft -= 8;
$result .= chr(($buffer >> $bitsLeft) & 0xFF);
}
}
return $result;
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use App\Models\Therapist;
/**
* Class WorkloadBalancer
* Enterprise Workload Calculation & Load Balancing Service
*
* @package App\Services
*/
class WorkloadBalancer
{
private Therapist $therapistModel;
public function __construct()
{
$this->therapistModel = new Therapist();
}
/**
* Update workload score for a therapist after queue completion or assignment
*
* Score Formula: (total_minutes_today / max_daily_minutes) * 10.0 + (in_progress_count * 2.0)
*/
public function updateScore(int $therapistId): float
{
$therapist = $this->therapistModel->find($therapistId);
if (!$therapist) return 0.00;
$maxMins = (int)($therapist['max_daily_minutes'] ?? 480);
if ($maxMins <= 0) $maxMins = 480;
// Query today's queues for this therapist
$db = \App\Models\Model::getDB();
$stmt = $db->prepare("SELECT
COALESCE(SUM(CASE WHEN status = 'Completed' THEN COALESCE(service_time_mins, 60) ELSE 0 END), 0) as completed_mins,
SUM(CASE WHEN status IN ('Assigned', 'In_Progress') THEN 1 ELSE 0 END) as active_queues
FROM `queues`
WHERE therapist_id = :tid AND queue_date = CURDATE()");
$stmt->execute(['tid' => $therapistId]);
$row = $stmt->fetch();
$completedMins = (float)($row['completed_mins'] ?? 0);
$activeCount = (float)($row['active_queues'] ?? 0);
// คำนวณคะแนนภาระงาน ยิ่งคะแนนต่ำยิ่งว่างและมีโอกาสได้รับคิวต่อไป
$score = round(($completedMins / $maxMins) * 10.0 + ($activeCount * 2.5), 2);
// อัปเดตลงตาราง
$this->therapistModel->update($therapistId, [
'current_workload_score' => $score,
'is_available' => ($activeCount == 0 && $completedMins < $maxMins) ? 1 : 0,
]);
return $score;
}
/**
* Reset all workload scores (Called by midnight event or manually)
*/
public function resetAll(): void
{
$db = \App\Models\Model::getDB();
$db->exec("CALL sp_reset_daily_workloads()");
}
}