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,139 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Helpers\Security;
use App\Helpers\Validator;
use App\Middleware\RateLimitMiddleware;
use App\Models\User;
use App\Services\AuditLogger;
use App\Services\TwoFactorService;
/**
* Class AuthController
* Authentication REST API Controller (13-digit National ID, Argon2id, 2FA TOTP, JWT)
*
* @package App\Controllers
*/
class AuthController
{
/**
* POST /api/v1/auth/login
*/
public function login(): void
{
RateLimitMiddleware::handle(10, 60); // ป้องกัน Brute-force สูงสุด 10 ครั้ง/นาทีต่อ IP
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$validator = Validator::make($input, [
'national_id' => 'required|cid_13',
'password' => 'required|min:8',
]);
if ($validator->fails()) {
Response::error('ข้อมูลเข้าสู่ระบบไม่ถูกต้อง (Validation Failed)', 422, $validator->getErrors());
}
$userModel = new User();
$authRes = $userModel->verifyCredentials(trim($input['national_id']), $input['password']);
if (!$authRes['success']) {
AuditLogger::logLoginFailed(trim($input['national_id']), $authRes['error']);
Response::error($authRes['error'], 401);
}
$user = $authRes['user'];
// ตรวจสอบว่าบัญชีนี้เปิดใช้งาน 2FA หรือไม่
if ((int)$user['two_factor_enabled'] === 1) {
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['pending_2fa_user_id'] = (int)$user['id'];
$_SESSION['pending_2fa_user_role'] = $user['role'];
$_SESSION['2fa_verified'] = false;
Response::success('2FA Verification Required', [
'require_2fa' => true,
'user_id' => (int)$user['id'],
'message' => 'กรุณากรอกรหัส OTP 6 หลักจากแอป Google Authenticator',
], 200);
}
// หากไม่เปิด 2FA ให้สร้าง JWT Access Token ทันที
$this->issueTokenAndRespond($user);
}
/**
* POST /api/v1/auth/verify-2fa
*/
public function verifyTwoFactor(): void
{
RateLimitMiddleware::handle(5, 60); // สูงสุด 5 ครั้ง/นาทีสำหรับ 2FA
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if (empty($input['code']) || strlen($input['code']) !== 6) {
Response::error('กรุณาระบุรหัส TOTP 6 หลักที่ถูกต้อง', 422);
}
if (session_status() === PHP_SESSION_NONE) session_start();
$userId = $_SESSION['pending_2fa_user_id'] ?? ($input['user_id'] ?? null);
if (!$userId) {
Response::error('Session หมดอายุ กรุณาเข้าสู่ระบบใหม่อีกครั้ง', 401);
}
$userModel = new User();
$user = $userModel->find((int)$userId);
if (!$user || (int)$user['two_factor_enabled'] !== 1 || empty($user['two_factor_secret'])) {
Response::error('ไม่พบการตั้งค่า 2FA หรือผู้ใช้งานไม่ถูกต้อง', 400);
}
// Verify TOTP Code
if (!TwoFactorService::verifyCode($user['two_factor_secret'], trim($input['code']))) {
Response::error('รหัส TOTP ไม่ถูกต้องหรือหมดเวลา กรุณาลองใหม่อีกครั้ง', 401);
}
$_SESSION['2fa_verified'] = true;
unset($user['password_hash'], $user['two_factor_secret'], $user['two_factor_recovery_codes']);
$this->issueTokenAndRespond($user);
}
private function issueTokenAndRespond(array $user): void
{
$config = require __DIR__ . '/../../config/security.php';
$secret = $config['jwt']['secret'];
$ttl = $config['jwt']['access_token_ttl'];
$payload = [
'sub' => (int)$user['id'],
'nat_id' => $user['national_id'],
'role' => $user['role'],
'name' => $user['first_name'] . ' ' . $user['last_name'],
'branch' => (int)($user['branch_id'] ?? 1),
];
$token = Security::generateJwt($payload, $secret, $ttl);
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['access_token'] = $token;
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['branch_id'] = (int)($user['branch_id'] ?? 1);
AuditLogger::logLogin((int)$user['id'], $user['role']);
Response::success('เข้าสู่ระบบสำเร็จ (Authentication Successful)', [
'access_token' => $token,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'user' => [
'id' => (int)$user['id'],
'national_id' => $user['national_id'],
'full_name' => $user['title'] . $user['first_name'] . ' ' . $user['last_name'],
'role' => $user['role'],
'branch_id' => (int)($user['branch_id'] ?? 1),
]
], 200);
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Controllers;
use App\Helpers\EscPosPrinter;
use App\Helpers\Response;
use App\Helpers\Validator;
use App\Middleware\JwtAuthMiddleware;
use App\Models\Payment;
use App\Models\Queue;
use App\Services\AuditLogger;
/**
* Class BillingController
* POS Cashier & Thermal Printing REST API Controller
*
* @package App\Controllers
*/
class BillingController
{
private Payment $paymentModel;
public function __construct()
{
$this->paymentModel = new Payment();
}
/**
* POST /api/v1/billing/checkout - Cashier Checkout & Payment Processing
*/
public function checkout(): void
{
$user = JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$validator = Validator::make($input, [
'queue_id' => 'required|numeric',
'patient_id' => 'required|numeric',
'subtotal' => 'required|numeric',
]);
if ($validator->fails()) {
Response::error('ข้อมูลการคิดเงินไม่ถูกต้อง', 422, $validator->getErrors());
}
$input['branch_id'] = $input['branch_id'] ?? $user['branch'];
$input['cashier_id'] = $user['id'];
$res = $this->paymentModel->processCheckout($input);
AuditLogger::logPayment($res['payment_id'], $res['receipt_no'], (float)$res['net_amount']);
Response::success('ชำระเงินและออกใบเสร็จรับเงินสำเร็จ', $res, 201);
}
/**
* GET /api/v1/billing/print-ticket/{queueId} - Get Base64 ESC/POS Queue Ticket Buffer
*/
public function printTicket(int $queueId): void
{
JwtAuthMiddleware::handle();
$queue = (new Queue())->find($queueId);
if (!$queue) {
Response::error('ไม่พบข้อมูลคิวสำหรับพิมพ์ใบคิว', 404);
}
// ดึงชื่อสาขา ผู้ป่วย และหมอนวด
$db = \App\Models\Model::getDB();
$stmt = $db->prepare("SELECT b.name_th as branch_name, CONCAT(p.first_name_th, ' ', p.last_name_th) as patient_name,
s.name_th as service_name, CONCAT(u.first_name, ' ', u.last_name) as therapist_name,
r.room_no
FROM queues q
JOIN branches b ON q.branch_id = b.id
JOIN patients p ON q.patient_id = p.id
JOIN services s ON q.service_id = s.id
LEFT JOIN therapists t ON q.therapist_id = t.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN rooms r ON q.room_id = r.id
WHERE q.id = :id");
$stmt->execute(['id' => $queueId]);
$details = $stmt->fetch();
$ticketData = array_merge($queue, $details ?: []);
$base64Buffer = EscPosPrinter::buildQueueTicket($ticketData);
Response::success('คำสั่งเครื่องพิมพ์ความร้อน ESC/POS (Base64)', [
'queue_id' => $queueId,
'queue_no' => $queue['queue_no'],
'printer_type' => 'ESC/POS Thermal 80mm/58mm',
'raw_base64_buffer' => $base64Buffer,
'webusb_ready' => true,
]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Helpers\Validator;
use App\Middleware\JwtAuthMiddleware;
use App\Models\SoapNote;
use App\Services\AuditLogger;
use App\Services\HisConnector;
/**
* Class ClinicalController
* Clinical Assessment & SOAP Note REST API Controller
*
* @package App\Controllers
*/
class ClinicalController
{
private SoapNote $soapModel;
public function __construct()
{
$this->soapModel = new SoapNote();
}
/**
* POST /api/v1/clinical/soap - Store or Update SOAP Note with VAS Pain Score & ROM
*/
public function storeSoap(): void
{
$user = JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$validator = Validator::make($input, [
'queue_id' => 'required|numeric',
'patient_id' => 'required|numeric',
'pre_pain_score' => 'vas_score',
'post_pain_score' => 'vas_score',
]);
if ($validator->fails()) {
Response::error('ข้อมูลการประเมินทางคลินิกไม่ถูกต้อง', 422, $validator->getErrors());
}
$input['therapist_id'] = $input['therapist_id'] ?? $user['id'];
$noteId = $this->soapModel->storeAssessment($input);
AuditLogger::logSoapNote($noteId, (int)$input['queue_id'], (int)$input['patient_id']);
// ส่งข้อมูลการรักษากลับไปยังระบบ HIS โรงพยาบาล
(new HisConnector())->sendTreatmentResult((int)$input['patient_id'], $input);
Response::success('บันทึกเวชระเบียน SOAP Note และส่งข้อมูลเข้า HIS สำเร็จ', [
'soap_note_id' => $noteId,
'queue_id' => (int)$input['queue_id'],
'status' => 'Saved',
], 201);
}
/**
* GET /api/v1/clinical/patient/{id} - Get Clinical History of Patient
*/
public function getPatientHistory(int $patientId): void
{
JwtAuthMiddleware::handle();
$history = $this->soapModel->getPatientHistory($patientId);
Response::success('ประวัติการรักษาและเวชระเบียนผู้ป่วย', [
'patient_id' => $patientId,
'total_records' => count($history),
'history' => $history,
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Middleware\JwtAuthMiddleware;
use App\Models\Patient;
use App\Models\Queue;
use App\Models\SoapNote;
use App\Services\FhirService;
use App\Services\HisConnector;
/**
* Class HisController
* Hospital Interoperability Controller (HIS / HOSxP Sync, Smart Card Reader, HL7 FHIR)
*
* @package App\Controllers
*/
class HisController
{
private HisConnector $hisConnector;
private FhirService $fhirService;
public function __construct()
{
$this->hisConnector = new HisConnector();
$this->fhirService = new FhirService();
}
/**
* POST /api/v1/his/sync - Sync Patient Data from HIS/HOSxP by HN or CID
*/
public function syncPatient(): void
{
JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$query = $input['query'] ?? '';
$type = $input['type'] ?? 'hn'; // hn or cid
if (empty($query)) {
Response::error('กรุณาระบุหมายเลข HN หรือเลขบัตรประชาชนที่ต้องการดึงข้อมูล', 400);
}
$hisData = $this->hisConnector->getPatientFromHis($query, $type);
if (!$hisData) {
Response::error('ไม่พบข้อมูลผู้ป่วยในระบบ HIS ของโรงพยาบาล', 404);
}
// Save or update to local database
$patientModel = new Patient();
$existing = $patientModel->firstWhere([$type === 'hn' ? 'hn' : 'cid' => $query]);
if ($existing) {
$patientModel->updateHisData((int)$existing['id'], $hisData);
$patientId = (int)$existing['id'];
} else {
$patientId = $patientModel->saveFromSmartCard([
'cid' => $hisData['cid'] ?? $query,
'hn' => $hisData['hn'] ?? $query,
'first_name_th' => $hisData['first_name_th'] ?? 'ไม่ระบุ',
'last_name_th' => $hisData['last_name_th'] ?? 'ไม่ระบุ',
'birth_date' => $hisData['birth_date'] ?? '1980-01-01',
'gender' => $hisData['gender'] ?? 'Male',
'photo_url' => null,
]);
$patientModel->updateHisData($patientId, $hisData);
}
$patient = $patientModel->find($patientId);
Response::success('ดึงข้อมูลผู้ป่วยจาก HIS โรงพยาบาลสำเร็จ', [
'patient' => $patient,
'his_raw' => $hisData,
]);
}
/**
* GET /api/v1/his/smartcard - Read data from Local Smart Card Agent APDU / WebUSB
*/
public function readSmartCard(): void
{
JwtAuthMiddleware::handle();
// จำลองข้อมูลจากการอ่านบัตรประชาชนไทย Smart Card (ผ่าน Local PC/SC Agent)
$mockCardData = [
'cid' => '1100000000009',
'first_name_th' => 'สมศักดิ์',
'last_name_th' => 'บัตรทอง',
'first_name_en' => 'Somsak',
'last_name_en' => 'Batthong',
'birth_date' => '1975-08-12',
'gender' => 'Male',
'address' => '100 ถนนดินสอ แขวงบวรนิเวศ เขตพระนคร กรุงเทพมหานคร 10200',
'photo_base64' => 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAA...',
];
$patientModel = new Patient();
$patientId = $patientModel->saveFromSmartCard($mockCardData);
$patient = $patientModel->find($patientId);
Response::success('อ่านข้อมูลบัตรประชาชนไทยและลงทะเบียนผู้ป่วยสำเร็จ', [
'patient' => $patient,
'card_read_time' => date('Y-m-d H:i:s'),
]);
}
/**
* POST /api/v1/fhir/encounter - Export Encounter & Pain Score to HL7 FHIR Server
*/
public function exportFhir(): void
{
JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$queueId = isset($input['queue_id']) ? (int)$input['queue_id'] : 0;
if (!$queueId) {
Response::error('กรุณาระบุรหัสคิว (queue_id)', 400);
}
$queue = (new Queue())->find($queueId);
$soap = (new SoapNote())->findByQueueId($queueId);
if (!$queue || !$soap) {
Response::error('ไม่พบข้อมูลคิวหรือเวชระเบียน SOAP Note', 404);
}
$bundle = $this->fhirService->exportEncounterBundle($queue, $soap);
Response::success('สร้างและส่งออกทรัพยากร HL7 FHIR R4 Bundle เรียบร้อยแล้ว', [
'fhir_bundle' => $bundle,
'exported_at' => date('c'),
]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Helpers\Validator;
use App\Middleware\JwtAuthMiddleware;
use App\Models\Patient;
/**
* Class PatientController
* Patient EMR & Registration REST API Controller
*
* @package App\Controllers
*/
class PatientController
{
private Patient $patientModel;
public function __construct()
{
$this->patientModel = new Patient();
}
/**
* GET /api/v1/patients/search?q={keyword}
*/
public function search(): void
{
JwtAuthMiddleware::handle();
$keyword = trim($_GET['q'] ?? '');
if (empty($keyword)) {
Response::success('รายชื่อผู้รับบริการทั้งหมด (ล่าสุด)', $this->patientModel->all(50));
} else {
Response::success("ผลการค้นหาผู้รับบริการสำหรับ '{$keyword}'", $this->patientModel->search($keyword));
}
}
/**
* POST /api/v1/patients - Create New Patient
*/
public function store(): void
{
JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$validator = Validator::make($input, [
'cid' => 'required|cid_13',
'first_name_th' => 'required|min:2',
'last_name_th' => 'required|min:2',
'birth_date' => 'required|date',
'phone' => 'required|min:9',
]);
if ($validator->fails()) {
Response::error('ข้อมูลลงทะเบียนผู้ป่วยไม่ถูกต้อง', 422, $validator->getErrors());
}
$existing = $this->patientModel->findByCid(trim($input['cid']));
if ($existing) {
Response::error('เลขบัตรประชาชนนี้มีอยู่ในระบบแล้ว (HN: ' . $existing['hn'] . ')', 409);
}
$hn = 'HN-' . date('ym') . rand(1000, 9999);
$patientId = $this->patientModel->create([
'cid' => trim($input['cid']),
'hn' => $hn,
'first_name_th' => trim($input['first_name_th']),
'last_name_th' => trim($input['last_name_th']),
'first_name_en' => $input['first_name_en'] ?? null,
'last_name_en' => $input['last_name_en'] ?? null,
'birth_date' => $input['birth_date'],
'gender' => $input['gender'] ?? 'Male',
'blood_group' => $input['blood_group'] ?? null,
'address' => $input['address'] ?? null,
'phone' => trim($input['phone']),
'line_id' => $input['line_id'] ?? null,
'email' => $input['email'] ?? null,
'underlying_diseases' => $input['underlying_diseases'] ?? 'ไม่มี',
'massage_contraindications' => $input['massage_contraindications'] ?? 'ไม่มี',
'drug_allergies' => $input['drug_allergies'] ?? 'ไม่มี',
'emergency_contact_name' => $input['emergency_contact_name'] ?? null,
'emergency_contact_phone' => $input['emergency_contact_phone'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Response::success('ลงทะเบียนผู้รับบริการใหม่เรียบร้อยแล้ว', $this->patientModel->find($patientId), 201);
}
}
@@ -0,0 +1,147 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Helpers\Validator;
use App\Middleware\JwtAuthMiddleware;
use App\Middleware\RateLimitMiddleware;
use App\Models\Queue;
use App\Services\AuditLogger;
use App\Services\NotificationService;
use App\Services\SmartQueueEngine;
/**
* Class QueueController
* Smart Queue & Realtime Board REST API Controller
*
* @package App\Controllers
*/
class QueueController
{
private Queue $queueModel;
private SmartQueueEngine $aiEngine;
public function __construct()
{
$this->queueModel = new Queue();
$this->aiEngine = new SmartQueueEngine();
}
/**
* GET /api/v1/queues - Realtime Queue Board
*/
public function index(): void
{
RateLimitMiddleware::handle(60, 60);
$branchId = isset($_GET['branch_id']) ? (int)$_GET['branch_id'] : null;
$board = $this->queueModel->getRealtimeBoard($branchId);
Response::success('Realtime Queue Board Data', [
'total_active' => count($board),
'branch_id' => $branchId ?: 'All',
'queues' => $board,
]);
}
/**
* POST /api/v1/queues/walkin - Create Walk-in Queue & Execute AI Assign
*/
public function walkin(): void
{
RateLimitMiddleware::handle(30, 60);
$user = JwtAuthMiddleware::handle(); // ต้องเป็นเจ้าหน้าที่ Reception/Admin ที่ล็อกอิน
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$validator = Validator::make($input, [
'patient_id' => 'required|numeric',
'service_id' => 'required|numeric',
'priority' => 'in:Normal,VIP,Emergency',
]);
if ($validator->fails()) {
Response::error('ข้อมูลไม่ครบถ้วนหรือไม่ถูกต้อง', 422, $validator->getErrors());
}
$input['branch_id'] = $input['branch_id'] ?? $user['branch'];
$input['created_by'] = $user['id'];
$input['booking_type'] = 'Walk_in';
// Execute Smart Queue AI Engine
$res = $this->aiEngine->allocateQueue($input);
Response::success('ออกคิวนวดสำเร็จ พร้อมประเมินเวลาโดย Smart AI', $res, 201);
}
/**
* POST /api/v1/queues/smart-assign - Manual Trigger AI Allocation for a waiting queue
*/
public function smartAssign(): void
{
JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if (empty($input['queue_id'])) {
Response::error('กรุณาระบุหมายเลขคิวที่ต้องการจัดสรร', 400);
}
$db = \App\Models\Model::getDB();
$stmt = $db->prepare("CALL sp_assign_smart_queue(:qid, @t_id, @r_id, @e_start, @e_end, @msg)");
$stmt->execute(['qid' => (int)$input['queue_id']]);
$stmt->closeCursor();
$res = $db->query("SELECT @t_id AS t_id, @r_id AS r_id, @e_start AS e_start, @e_end AS e_end, @msg AS msg")->fetch();
Response::success('Smart AI Allocation Executed', $res);
}
/**
* PUT /api/v1/queues/{id}/status - Update Queue Status (Calling / In_Progress / Completed / Cancelled)
*/
public function updateStatus(int $queueId): void
{
$user = JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$newStatus = $input['status'] ?? null;
$allowed = ['Waiting', 'Assigned', 'In_Progress', 'Completed', 'Cancelled', 'No_Show'];
if (!in_array($newStatus, $allowed, true)) {
Response::error('สถานะคิวไม่ถูกต้อง', 400);
}
$existing = $this->queueModel->find($queueId);
if (!$existing) {
Response::error('ไม่พบข้อมูลคิวนี้', 404);
}
$updateData = [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($newStatus === 'In_Progress') {
$updateData['actual_start_time'] = date('Y-m-d H:i:s');
// แจ้งเรียกคิวออกทีวีและ LINE Notify
if (!empty($existing['room_id'])) {
$room = (new \App\Models\Room())->find((int)$existing['room_id']);
(new NotificationService())->notifyQueueCalled($existing['queue_no'], $room['room_no'] ?? '-', 'ผู้รับบริการ');
}
} elseif ($newStatus === 'Completed') {
$updateData['actual_end_time'] = date('Y-m-d H:i:s');
if (!empty($existing['actual_start_time'])) {
$mins = round((time() - strtotime($existing['actual_start_time'])) / 60);
$updateData['service_time_mins'] = $mins > 0 ? $mins : 60;
}
}
$this->queueModel->update($queueId, $updateData);
AuditLogger::logQueueUpdate($queueId, $existing['queue_no'], $existing['status'], $newStatus);
// คำนวณ Workload ใหม่ให้หมอนวดหากคิวจบหรือยกเลิก
if (!empty($existing['therapist_id'])) {
(new \App\Services\WorkloadBalancer())->updateScore((int)$existing['therapist_id']);
}
Response::success("อัปเดตสถานะคิวเป็น {$newStatus} เรียบร้อยแล้ว", ['queue_id' => $queueId, 'status' => $newStatus]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Middleware\JwtAuthMiddleware;
use App\Middleware\RoleGuard;
use App\Models\Model;
/**
* Class ReportController
* Executive Dashboard & Daily KPI Report Controller
*
* @package App\Controllers
*/
class ReportController
{
/**
* GET /api/v1/reports/kpi - Get Daily Executive KPI
*/
public function kpi(): void
{
$user = JwtAuthMiddleware::handle();
RoleGuard::check(['Admin', 'Manager']);
$date = $_GET['date'] ?? date('Y-m-d');
$branchId = isset($_GET['branch_id']) ? (int)$_GET['branch_id'] : $user['branch'];
$db = Model::getDB();
$stmt = $db->prepare("CALL sp_generate_daily_kpi(:pdate, :pbranch)");
$stmt->execute(['pdate' => $date, 'pbranch' => $branchId]);
$kpi = $stmt->fetch();
$stmt->closeCursor();
// ดึงสถิติภาระงานหมอนวดทั้งหมดจาก View
$workloadStmt = $db->prepare("SELECT * FROM vw_therapist_workload_summary WHERE branch_id = :bid ORDER BY completed_queues_today DESC");
$workloadStmt->execute(['bid' => $branchId]);
$therapistWorkloads = $workloadStmt->fetchAll();
Response::success('รายงานสรุปผลงานและ KPI ประจำวัน', [
'date' => $date,
'branch_id' => $branchId,
'kpi_summary' => $kpi,
'therapist_workloads' => $therapistWorkloads,
]);
}
/**
* GET /api/v1/reports/export - Export Revenue and Queue Report as CSV / Excel
*/
public function export(): void
{
JwtAuthMiddleware::handle();
RoleGuard::check(['Admin', 'Manager']);
$date = $_GET['date'] ?? date('Y-m-d');
$db = Model::getDB();
$stmt = $db->prepare("SELECT q.queue_no, q.queue_date, p.hn, CONCAT(p.first_name_th, ' ', p.last_name_th) AS patient,
s.name_th AS service, CONCAT(u.first_name, ' ', u.last_name) AS therapist,
q.status, q.wait_time_mins, q.service_time_mins, pm.net_amount
FROM queues q
JOIN patients p ON q.patient_id = p.id
JOIN services s ON q.service_id = s.id
LEFT JOIN therapists t ON q.therapist_id = t.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN payments pm ON pm.queue_id = q.id
WHERE q.queue_date = :dt
ORDER BY q.id ASC");
$stmt->execute(['dt' => $date]);
$rows = $stmt->fetchAll();
header('Content-Type: text/csv; charset=UTF-8');
header("Content-Disposition: attachment; filename=TTMQMS_Report_{$date}.csv");
$out = fopen('php://output', 'w');
// BOM for UTF-8 in Excel
fputs($out, "\xEF\xBB\xBF");
fputcsv($out, ['ลำดับคิว', 'วันที่', 'HN', 'ผู้รับบริการ', 'รายการนวด', 'หมอนวด', 'สถานะ', 'เวลารอ (นาที)', 'เวลาบริการ (นาที)', 'รายได้สุทธิ (บาท)']);
foreach ($rows as $row) {
fputcsv($out, [
$row['queue_no'], $row['queue_date'], $row['hn'], $row['patient'],
$row['service'], $row['therapist'], $row['status'], $row['wait_time_mins'],
$row['service_time_mins'], $row['net_amount']
]);
}
fclose($out);
exit();
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Middleware\JwtAuthMiddleware;
use App\Models\Room;
/**
* Class RoomController
* Massage Room Realtime Status REST API Controller
*
* @package App\Controllers
*/
class RoomController
{
private Room $roomModel;
public function __construct()
{
$this->roomModel = new Room();
}
/**
* GET /api/v1/rooms - Get rooms status board
*/
public function index(): void
{
$user = JwtAuthMiddleware::handle();
$branchId = isset($_GET['branch_id']) ? (int)$_GET['branch_id'] : $user['branch'];
Response::success('สถานะการใช้งานห้องนวดแบบ Realtime', $this->roomModel->getStatusBoard($branchId));
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Models\Service;
/**
* Class ServiceController
* Massage Services & Packages REST API Controller
*
* @package App\Controllers
*/
class ServiceController
{
private Service $serviceModel;
public function __construct()
{
$this->serviceModel = new Service();
}
/**
* GET /api/v1/services - Get active services
*/
public function index(): void
{
$branchId = isset($_GET['branch_id']) ? (int)$_GET['branch_id'] : null;
Response::success('รายการบริการและคอร์สนวดแผนไทยทั้งหมด', $this->serviceModel->getActive($branchId));
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Middleware\JwtAuthMiddleware;
use App\Middleware\RoleGuard;
use App\Models\Model;
/**
* Class SettingController
* System Settings & Configuration REST API Controller
*
* @package App\Controllers
*/
class SettingController
{
/**
* GET /api/v1/settings - Get all system settings
*/
public function index(): void
{
$db = Model::getDB();
$stmt = $db->query("SELECT * FROM system_settings");
$settings = $stmt->fetchAll();
$formatted = [];
foreach ($settings as $s) {
$formatted[$s['setting_key']] = $s['setting_value'];
}
Response::success('ค่าการตั้งค่าระบบทั้งหมด', $formatted);
}
/**
* PUT /api/v1/settings - Update Settings (Admin only)
*/
public function update(): void
{
JwtAuthMiddleware::handle();
RoleGuard::check(['Admin', 'Manager']);
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if (empty($input) || !is_array($input)) {
Response::error('ข้อมูลการตั้งค่าไม่ถูกต้อง', 400);
}
$db = Model::getDB();
$stmt = $db->prepare("INSERT INTO system_settings (setting_key, setting_value, setting_group)
VALUES (:key, :val, 'general')
ON DUPLICATE KEY UPDATE setting_value = :val2");
foreach ($input as $key => $val) {
$stmt->execute(['key' => $key, 'val' => (string)$val, 'val2' => (string)$val]);
}
Response::success('บันทึกการตั้งค่าระบบเรียบร้อยแล้ว', $input);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Controllers;
use App\Helpers\Response;
use App\Middleware\JwtAuthMiddleware;
use App\Models\Therapist;
/**
* Class TherapistController
* Therapist Workload & Roster REST API Controller
*
* @package App\Controllers
*/
class TherapistController
{
private Therapist $therapistModel;
public function __construct()
{
$this->therapistModel = new Therapist();
}
/**
* GET /api/v1/therapists - Get all therapists with profiles & workload score
*/
public function index(): void
{
JwtAuthMiddleware::handle();
$branchId = isset($_GET['branch_id']) ? (int)$_GET['branch_id'] : null;
Response::success('รายชื่อหมอนวดและสถานะภาระงาน (Workload)', $this->therapistModel->getWithProfiles($branchId));
}
/**
* PUT /api/v1/therapists/{id}/availability - Toggle Availability
*/
public function toggleAvailability(int $id): void
{
JwtAuthMiddleware::handle();
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$available = !empty($input['is_available']);
$this->therapistModel->setAvailability($id, $available);
Response::success('อัปเดตสถานะความพร้อมให้บริการของหมอนวดแล้ว', ['therapist_id' => $id, 'is_available' => $available ? 1 : 0]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Exceptions;
use App\Helpers\Response;
use PDOException;
use Throwable;
/**
* Class Handler
* Enterprise Global Exception & Error Handler (OWASP Information Leakage Guard)
*
* @package App\Exceptions
*/
class Handler
{
/**
* Register Global Exception and Error Handlers
*/
public static function register(): void
{
set_exception_handler([self::class, 'handleException']);
set_error_handler([self::class, 'handleError']);
register_shutdown_function([self::class, 'handleShutdown']);
}
/**
* Handle uncaught exceptions
*/
public static function handleException(Throwable $e): void
{
$status = 500;
$message = "Internal Server Error";
$details = [];
if ($e instanceof PDOException) {
$status = 500;
$message = "Database Query Error";
// ในโหมด Debug จะแสดง Error จริง ถ้า Production ซ่อนรายละเอียดป้องกัน SQLi enumeration
$details = getenv('APP_DEBUG') === 'true' ? ['pdo_error' => $e->getMessage()] : ['info' => 'Please check database logs'];
error_log("PDOException: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
} elseif (is_int($e->getCode()) && $e->getCode() >= 400 && $e->getCode() <= 599) {
$status = $e->getCode();
$message = $e->getMessage();
} else {
$message = $e->getMessage() ?: 'Unexpected Error Occurred';
error_log("Exception: " . $message . " in " . $e->getFile() . ":" . $e->getLine());
}
if (getenv('APP_DEBUG') === 'true') {
$details['file'] = $e->getFile();
$details['line'] = $e->getLine();
$details['trace'] = explode("\n", $e->getTraceAsString());
}
if (self::isApiRequest()) {
Response::error($message, $status, $details);
} else {
self::renderHtmlError($status, $message, $details, $e);
}
}
/**
* Handle PHP Warnings/Notices as Exceptions
*
* @param int $level
* @param string $message
* @param string $file
* @param int $line
* @return bool
*/
public static function handleError(int $level, string $message, string $file, int $line): bool
{
if (!(error_reporting() & $level)) {
return false;
}
error_log("PHP Error [{$level}]: {$message} in {$file}:{$line}");
return true;
}
/**
* Handle Fatal Errors on shutdown
*/
public static function handleShutdown(): void
{
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
error_log("Fatal Shutdown Error: {$error['message']} in {$error['file']}:{$error['line']}");
if (self::isApiRequest()) {
Response::error("Fatal Server Error: {$error['message']}", 500);
} else {
self::renderHtmlError(500, "Fatal PHP Error: " . $error['message'], ['file' => $error['file'], 'line' => $error['line']]);
}
}
}
private static function isApiRequest(): bool
{
$uri = (string)($_SERVER['REQUEST_URI'] ?? '');
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
return (strpos($uri, '/api/') !== false) ||
(strpos($uri, 'api=') !== false) ||
(strpos($uri, 'endpoint=') !== false) ||
(strpos($accept, 'application/json') !== false) ||
isset($_GET['api']) || isset($_GET['endpoint']);
}
/**
* Render HTML Error Page
*
* @param int $status
* @param string $message
* @param array<string, mixed> $details
* @param Throwable|null $e
*/
private static function renderHtmlError(int $status, string $message, array $details = [], ?Throwable $e = null): void
{
http_response_code($status);
$debugHtml = '';
if ((getenv('APP_DEBUG') === 'true' || getenv('APP_ENV') === 'local') && ($details || $e)) {
$errText = $e ? htmlspecialchars($e->getMessage()) : htmlspecialchars(json_encode($details, JSON_UNESCAPED_UNICODE));
$errFile = $e ? htmlspecialchars($e->getFile() . ':' . $e->getLine()) : '';
$debugHtml = "<div class='mt-4 p-4 bg-slate-950/80 rounded-xl text-left border border-rose-500/30 overflow-auto max-h-60 text-xs font-mono text-rose-300'>
<p class='font-bold underline mb-1'>Debug Information (APP_DEBUG=true):</p>
<p class='mb-2'><strong>Message:</strong> {$errText}</p>
" . ($errFile ? "<p class='mb-2'><strong>Location:</strong> {$errFile}</p>" : "") . "
</div>";
}
echo "<!DOCTYPE html>
<html lang='th'>
<head>
<meta charset='UTF-8'>
<title>Error {$status} - TTMQMS</title>
<script src='https://cdn.tailwindcss.com'></script>
</head>
<body class='bg-slate-900 text-slate-100 flex items-center justify-center min-h-screen font-sans p-4'>
<div class='bg-slate-800 border border-slate-700 rounded-2xl p-8 max-w-xl w-full text-center shadow-2xl'>
<div class='text-6xl mb-4'>⚠️</div>
<h1 class='text-4xl font-bold text-rose-500 mb-2'>{$status}</h1>
<p class='text-lg text-slate-300 mb-4'>{$message}</p>
{$debugHtml}
<div class='mt-6'>
<a href='" . (defined('BASE_URL') ? BASE_URL : '') . "/' class='bg-emerald-600 hover:bg-emerald-500 text-white font-medium px-6 py-2.5 rounded-xl transition inline-block'>กลับสู่หน้าหลัก / รีเฟรช</a>
</div>
</div>
</body>
</html>";
exit();
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Helpers;
/**
* Class Argon2Hasher
* Enterprise Argon2id Password Hashing & Password Policy Guard
*
* @package App\Helpers
*/
class Argon2Hasher
{
/**
* Hash a plaintext password using Argon2id
*
* @param string $password Plaintext password
* @return string Hashed password
*/
public static function hash(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 passes
'threads' => 1,
]);
}
/**
* Verify plaintext password against Argon2id hash
*
* @param string $password Plaintext password
* @param string $hash Hashed password stored in database
* @return bool
*/
public static function verify(string $password, string $hash): bool
{
if (empty($hash)) return false;
return password_verify($password, $hash);
}
/**
* Check if password needs rehash due to upgraded algorithm or parameters
*
* @param string $hash
* @return bool
*/
public static function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
]);
}
/**
* Validate Password Strength against Enterprise Security Policy
*
* @param string $password
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validatePolicy(string $password): array
{
$errors = [];
if (strlen($password) < 8) {
$errors[] = "รหัสผ่านต้องมีความยาวอย่างน้อย 8 ตัวอักษร";
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวอักษรภาษาอังกฤษพิมพ์ใหญ่อย่างน้อย 1 ตัว (A-Z)";
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวอักษรภาษาอังกฤษพิมพ์เล็กอย่างน้อย 1 ตัว (a-z)";
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวเลขอย่างน้อย 1 ตัว (0-9)";
}
if (!preg_match('/[\W_]/', $password)) {
$errors[] = "รหัสผ่านต้องมีอักขระพิเศษอย่างน้อย 1 ตัว (!@#$%^&* เป็นต้น)";
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Helpers;
/**
* Class EscPosPrinter
* Enterprise Thermal Printer ESC/POS Command Builder (Queue Tickets & Tax Invoices)
*
* @package App\Helpers
*/
class EscPosPrinter
{
private string $buffer = "";
private string $ip;
private int $port;
// ESC/POS Command Constants
const ESC = "\x1B";
const GS = "\x1D";
const LF = "\x0A";
public function __construct(string $ip = '192.168.1.200', int $port = 9100)
{
$this->ip = $ip;
$this->port = $port;
$this->init();
}
/**
* Initialize Printer
*/
public function init(): self
{
$this->buffer .= self::ESC . "@"; // Reset
return $this;
}
/**
* Set text alignment
* @param string $align 'left' | 'center' | 'right'
*/
public function setAlign(string $align): self
{
$val = 0;
if ($align === 'center') $val = 1;
if ($align === 'right') $val = 2;
$this->buffer .= self::ESC . "a" . chr($val);
return $this;
}
/**
* Set text size
* @param int $width 1-8
* @param int $height 1-8
*/
public function setSize(int $width = 1, int $height = 1): self
{
$w = ($width - 1) << 4;
$h = ($height - 1);
$this->buffer .= self::GS . "!" . chr($w | $h);
return $this;
}
/**
* Set font emphasis (Bold)
*/
public function setBold(bool $enabled = true): self
{
$this->buffer .= self::ESC . "E" . chr($enabled ? 1 : 0);
return $this;
}
/**
* Print text line with Line Feed
*/
public function text(string $text): self
{
// ในระบบ Thermal Printer ไทยมักใช้ TIS-620 หรือ CP874
// แปลง UTF-8 เป็น CP874
$converted = @iconv('UTF-8', 'CP874//IGNORE', $text);
$this->buffer .= ($converted !== false ? $converted : $text) . self::LF;
return $this;
}
/**
* Print Horizontal Line separator (80mm = 48 chars)
*/
public function line(): self
{
$this->text(str_repeat('-', 48));
return $this;
}
/**
* Feed paper and cut
*/
public function cut(): self
{
$this->buffer .= self::LF . self::LF . self::LF;
$this->buffer .= self::GS . "V" . chr(66) . chr(0); // Partial cut
return $this;
}
/**
* Get RAW buffer for WebUSB / Browser Print
*/
public function getBuffer(): string
{
return $this->buffer;
}
/**
* Get Base64 encoded buffer for JSON API transfer to POS Frontend
*/
public function getBase64Buffer(): string
{
return base64_encode($this->buffer);
}
/**
* Send command directly to Network LAN Printer via Socket
* @return bool
*/
public function sendToPrinter(): bool
{
$fp = @fsockopen($this->ip, $this->port, $errno, $errstr, 2.0);
if (!$fp) {
error_log("ESC/POS Printer Connect Failed ({$this->ip}:{$this->port}) - {$errstr} ({$errno})");
return false;
}
fwrite($fp, $this->buffer);
fclose($fp);
return true;
}
/**
* Build Queue Ticket Buffer (ใบคิวสำหรับผู้ป่วย)
*/
public static function buildQueueTicket(array $queueData): string
{
$printer = new self();
$printer->setAlign('center')
->setSize(2, 2)->setBold(true)->text("ใบคิวนวดแผนไทย")
->setSize(1, 1)->setBold(false)->text($queueData['branch_name'] ?? "TTMQMS Center")
->line()
->setSize(3, 3)->setBold(true)->text($queueData['queue_no'])
->setSize(1, 1)->setBold(false)->text("ประเภทคิว: " . ($queueData['priority'] ?? 'Normal'))
->line()
->setAlign('left')
->text("ผู้รับบริการ: " . ($queueData['patient_name'] ?? '-'))
->text("บริการ: " . ($queueData['service_name'] ?? '-'))
->text("หมอนวด: " . ($queueData['therapist_name'] ?? 'รอจัดสรร (AI Queue)'))
->text("ห้องนวด: " . ($queueData['room_no'] ?? '-'))
->line()
->setAlign('center')
->text("เวลาออกคิว: " . ($queueData['checkin_time'] ?? date('Y-m-d H:i:s')))
->text("โปรดรอเรียกคิวที่หน้าจอ TV Display")
->text("*** ขอบพระคุณที่ใช้บริการ ***")
->cut();
return $printer->getBase64Buffer();
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Helpers;
/**
* Class Response
* Standardized JSON REST API Response Builder (PSR-7 Style)
*
* @package App\Helpers
*/
class Response
{
/**
* Send raw JSON response
*
* @param mixed $data
* @param int $status HTTP Status Code
* @param array $headers Additional headers
*/
public static function json($data, int $status = 200, array $headers = []): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('X-Powered-By: TTMQMS Enterprise API Engine v2.0');
foreach ($headers as $key => $val) {
header("{$key}: {$val}");
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit();
}
/**
* Send API Success Response
*
* @param string $message
* @param mixed $data
* @param int $status
*/
public static function success(string $message = 'Success', $data = null, int $status = 200): void
{
self::json([
'success' => true,
'status_code' => $status,
'message' => $message,
'data' => $data,
'timestamp' => date('Y-m-d H:i:s'),
], $status);
}
/**
* Send API Error Response
*
* @param string $message
* @param int $status
* @param array $errors
*/
public static function error(string $message = 'Error occurred', int $status = 400, array $errors = []): void
{
self::json([
'success' => false,
'status_code' => $status,
'error' => [
'message' => $message,
'details' => $errors,
],
'timestamp' => date('Y-m-d H:i:s'),
], $status);
}
/**
* Send Paginated API Response
*
* @param array $items
* @param int $total
* @param int $page
* @param int $limit
* @param string $message
*/
public static function paginate(array $items, int $total, int $page = 1, int $limit = 20, string $message = 'Data retrieved successfully'): void
{
$totalPages = $limit > 0 ? (int)ceil($total / $limit) : 1;
self::json([
'success' => true,
'status_code' => 200,
'message' => $message,
'data' => $items,
'pagination' => [
'total_items' => $total,
'current_page' => $page,
'items_per_page' => $limit,
'total_pages' => $totalPages,
'has_next' => $page < $totalPages,
'has_prev' => $page > 1,
],
'timestamp' => date('Y-m-d H:i:s'),
], 200);
}
/**
* Redirect to URL (For Frontend Views)
*
* @param string $url
* @param int $status
*/
public static function redirect(string $url, int $status = 302): void
{
http_response_code($status);
header("Location: {$url}");
exit();
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Helpers;
/**
* Class Security
* Enterprise Security Utilities (JWT Encoder/Decoder, XSS Sanitizer, CSRF, CSP)
*
* @package App\Helpers
*/
class Security
{
/**
* Sanitize String against XSS (Cross-Site Scripting)
*
* @param string|null $input
* @return string
*/
public static function clean(?string $input): string
{
if ($input === null) return '';
return htmlspecialchars(trim($input), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Recursive XSS Sanitize for Array or Object
*
* @param mixed $data
* @return mixed
*/
public static function cleanArray($data)
{
if (is_array($data)) {
return array_map([self::class, 'cleanArray'], $data);
}
if (is_string($data)) {
return self::clean($data);
}
return $data;
}
/**
* Generate CSRF Token and store in Session
*
* @return string
*/
public static function generateCsrfToken(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Verify CSRF Token
*
* @param string|null $token
* @return bool
*/
public static function verifyCsrfToken(?string $token): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Generate JSON Web Token (JWT RFC7519)
*
* @param array $payload
* @param string $secret
* @param int $ttl Seconds
* @return string
*/
public static function generateJwt(array $payload, string $secret, int $ttl = 3600): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload['iat'] = time();
$payload['exp'] = time() + $ttl;
$payload['iss'] = 'https://ttmqms.hospital.local';
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode(json_encode($payload));
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
/**
* Verify and Decode JWT Token
*
* @param string $token
* @param string $secret
* @return array|null Payload array if valid, null if invalid or expired
*/
public static function decodeJwt(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header64, $payload64, $sig64] = $parts;
$signature = self::base64UrlDecode($sig64);
$expectedSig = hash_hmac('sha256', $header64 . "." . $payload64, $secret, true);
if (!hash_equals($expectedSig, $signature)) {
return null; // Signature mismatch (Tampered)
}
$payload = json_decode(self::base64UrlDecode($payload64), true);
if (!$payload || !isset($payload['exp']) || $payload['exp'] < time()) {
return null; // Token expired
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
/**
* Get Client IP Address (Supporting Proxy X-Forwarded-For)
*/
public static function getClientIp(): string
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Helpers;
/**
* Class Validator
* Enterprise Data Validation Engine (Input & API Request Guard)
*
* @package App\Helpers
*/
class Validator
{
private array $errors = [];
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Create and run validation rules
*
* @param array $data
* @param array $rules Rule definitions e.g. ['national_id' => 'required|cid_13']
* @return self
*/
public static function make(array $data, array $rules): self
{
$validator = new self($data);
foreach ($rules as $field => $ruleStr) {
$ruleList = explode('|', $ruleStr);
foreach ($ruleList as $rule) {
if (strpos((string)$rule, ':') !== false) {
[$ruleName, $param] = explode(':', $rule, 2);
$validator->applyRule($field, $ruleName, $param);
} else {
$validator->applyRule($field, $rule, null);
}
}
}
return $validator;
}
private function applyRule(string $field, string $rule, ?string $param): void
{
$val = $this->data[$field] ?? null;
if ($rule === 'required' && ($val === null || trim((string)$val) === '')) {
$this->addError($field, "ฟิลด์ {$field} เป็นข้อมูลที่ต้องระบุ (Required)");
return;
}
if ($val === null || trim((string)$val) === '') {
return; // ข้ามการตรวจสอบอื่นถ้าค่าเป็นว่างและไม่ได้เป็น required
}
switch ($rule) {
case 'cid_13':
if (!preg_match('/^[0-9]{13}$/', (string)$val)) {
$this->addError($field, "เลขบัตรประชาชนต้องเป็นตัวเลข 13 หลักเท่านั้น");
} elseif (!$this->verifyThaiIDChecksum((string)$val)) {
$this->addError($field, "เลขบัตรประชาชน 13 หลักไม่ถูกต้องตามสูตรคำนวณ Checksum");
}
break;
case 'numeric':
if (!is_numeric($val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นตัวเลขเท่านั้น");
}
break;
case 'email':
if (!filter_var($val, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, "รูปแบบอีเมลไม่ถูกต้อง");
}
break;
case 'min':
if (strlen((string)$val) < (int)$param) {
$this->addError($field, "ฟิลด์ {$field} ต้องมีความยาวอย่างน้อย {$param} ตัวอักษร");
}
break;
case 'max':
if (strlen((string)$val) > (int)$param) {
$this->addError($field, "ฟิลด์ {$field} ต้องมีความยาวไม่เกิน {$param} ตัวอักษร");
}
break;
case 'in':
$allowed = explode(',', (string)$param);
if (!in_array($val, $allowed, true)) {
$this->addError($field, "ค่าในฟิลด์ {$field} ไม่ถูกต้อง (ต้องเป็น: " . implode(', ', $allowed) . ")");
}
break;
case 'date':
if (!strtotime((string)$val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นวันที่ที่ถูกต้อง (YYYY-MM-DD)");
}
break;
case 'time':
if (!preg_match('/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/', (string)$val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นเวลาที่ถูกต้อง (HH:MM หรือ HH:MM:SS)");
}
break;
case 'vas_score':
if (!is_numeric($val) || (int)$val < 0 || (int)$val > 10) {
$this->addError($field, "ระดับความปวด VAS Score ต้องเป็นตัวเลขระหว่าง 0 ถึง 10");
}
break;
}
}
/**
* ตรวจสอบ Checksum เลขบัตรประชาชนไทย 13 หลัก ตามหลักโมดูโล 11
*/
private function verifyThaiIDChecksum(string $cid): bool
{
if (strlen($cid) !== 13) return false;
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$sum += (int)$cid[$i] * (13 - $i);
}
$checkDigit = (11 - ($sum % 11)) % 10;
return (int)$cid[12] === $checkDigit;
}
private function addError(string $field, string $msg): void
{
$this->errors[$field][] = $msg;
}
public function fails(): bool
{
return !empty($this->errors);
}
public function getErrors(): array
{
return $this->errors;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Middleware;
use App\Helpers\Response;
use App\Helpers\Security;
/**
* Class CsrfMiddleware
* Cross-Site Request Forgery (CSRF) Protection Middleware
*
* @package App\Middleware
*/
class CsrfMiddleware
{
/**
* Verify CSRF token for state-changing requests (POST, PUT, DELETE)
*/
public static function handle(): void
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
$headers = getallheaders();
$token = $headers['X-CSRF-Token'] ?? $headers['x-csrf-token'] ?? $_POST['_csrf_token'] ?? null;
if (!Security::verifyCsrfToken($token)) {
Response::error("CSRF Token Validation Failed: Request is forged or session expired (กรุณารีเฟรชหน้าเว็บ)", 403);
}
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Middleware;
use App\Helpers\Response;
use App\Helpers\Security;
use App\Models\User;
/**
* Class JwtAuthMiddleware
* Enterprise JWT Authentication & Session Validation Middleware
*
* @package App\Middleware
*/
class JwtAuthMiddleware
{
/**
* Handle incoming request validation
*
* @return array Authenticated User Data
*/
public static function handle(): array
{
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = null;
if (preg_match('/^Bearer\s+(.*?)$/i', $authHeader, $matches)) {
$token = trim($matches[1]);
} elseif (!empty($_SESSION['access_token'])) {
$token = $_SESSION['access_token'];
}
if (empty($token)) {
Response::error('Unauthorized: No authentication token provided (กรุณาเข้าสู่ระบบ)', 401);
}
$config = require __DIR__ . '/../../config/security.php';
$secret = $config['jwt']['secret'];
$payload = Security::decodeJwt($token, $secret);
if (!$payload || !isset($payload['sub'])) {
Response::error('Unauthorized: Invalid or expired token (Token หมดอายุหรือไม่ถูกต้อง)', 401);
}
// Validate user existence and status in DB
$userModel = new User();
$user = $userModel->find((int)$payload['sub']);
if (!$user || (int)$user['is_active'] !== 1) {
Response::error('Unauthorized: User account is inactive or deleted', 401);
}
// Set global session context
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['user_role'] = $user['role'];
$_SESSION['branch_id'] = (int)($user['branch_id'] ?? 1);
unset($user['password_hash'], $user['two_factor_secret'], $user['two_factor_recovery_codes']);
return $user;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Middleware;
use App\Helpers\Response;
use App\Helpers\Security;
use App\Models\Model;
/**
* Class RateLimitMiddleware
* Enterprise API Rate Limiter & DDOS Mitigation Guard
*
* @package App\Middleware
*/
class RateLimitMiddleware
{
/**
* Handle rate limiting check (Max 60 requests per minute per IP)
*/
public static function handle(int $maxRequests = 60, int $windowSeconds = 60): void
{
$ip = Security::getClientIp();
$key = "rate_limit:ip:" . md5($ip);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Use Session / APCu / Redis / File-based tracking for clean fallback without crashing if Redis is offline
$now = time();
$record = $_SESSION[$key] ?? ['count' => 0, 'start' => $now];
if (($now - $record['start']) >= $windowSeconds) {
$record = ['count' => 1, 'start' => $now];
} else {
$record['count']++;
}
$_SESSION[$key] = $record;
// Set Rate Limit Headers
header("X-RateLimit-Limit: {$maxRequests}");
header("X-RateLimit-Remaining: " . max(0, $maxRequests - $record['count']));
header("X-RateLimit-Reset: " . ($record['start'] + $windowSeconds));
if ($record['count'] > $maxRequests) {
Response::error("Too Many Requests: ท่านส่งคำขอเข้ามาถี่เกินไป กรุณารอ 1 นาทีก่อนลองใหม่อีกครั้ง (DDOS Protection)", 429);
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Middleware;
use App\Helpers\Response;
/**
* Class RoleGuard
* Role-Based Access Control (RBAC) Guard Middleware
*
* @package App\Middleware
*/
class RoleGuard
{
/**
* Verify if current authenticated user has one of the required roles
*
* @param array $allowedRoles e.g. ['Admin', 'Manager', 'Doctor']
*/
public static function check(array $allowedRoles): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$userRole = $_SESSION['user_role'] ?? null;
if (!$userRole || !in_array($userRole, $allowedRoles, true)) {
Response::error("Forbidden: Your role ({$userRole}) does not have permission to access this resource (คุณไม่มีสิทธิ์เข้าถึงฟังก์ชันนี้)", 403);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Middleware;
use App\Helpers\Response;
use App\Models\User;
/**
* Class TwoFactorGuard
* 2FA (Google Authenticator TOTP) Verification Guard Middleware
*
* @package App\Middleware
*/
class TwoFactorGuard
{
/**
* Ensure user has verified 2FA if enabled on their account
*/
public static function check(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$userId = $_SESSION['user_id'] ?? null;
if (!$userId) {
Response::error("Unauthorized: Please login first", 401);
}
$userModel = new User();
$user = $userModel->find((int)$userId);
if ($user && (int)$user['two_factor_enabled'] === 1) {
$isVerified = $_SESSION['2fa_verified'] ?? false;
if (!$isVerified) {
Response::error("2FA Required: บัญชีของท่านเปิดใช้งาน 2-Factor Authentication กรุณายืนยันรหัส TOTP 6 หลักจาก Google Authenticator ก่อนใช้งาน", 403, [
'require_2fa' => true,
'redirect' => '/verify-2fa',
]);
}
}
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use App\Helpers\Security;
/**
* Class AuditLog
* Enterprise Audit Trail Logger (OWASP Non-repudiation Compliance)
*
* @package App\Models
*/
class AuditLog extends Model
{
protected string $table = 'audit_logs';
/**
* Record an audit trail log entry
*
* @param string $action e.g. 'LOGIN_SUCCESS', 'QUEUE_CREATE', 'SOAP_SIGN'
* @param string $entityType e.g. 'users', 'queues', 'soap_notes'
* @param int|null $entityId
* @param array|null $oldValues
* @param array|null $newValues
* @param int|null $userId
* @param int|null $branchId
*/
public static function record(
string $action,
string $entityType,
?int $entityId = null,
?array $oldValues = null,
?array $newValues = null,
?int $userId = null,
?int $branchId = null
): void {
try {
$log = new self();
$log->create([
'user_id' => $userId ?? ($_SESSION['user_id'] ?? null),
'branch_id' => $branchId ?? ($_SESSION['branch_id'] ?? 1),
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'old_values' => $oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
'new_values' => $newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
'ip_address' => Security::getClientIp(),
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? 'CLI/Unknown', 0, 255),
'created_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// ห้ามให้การบันทึก Log ล้มเหลวไปกระทบการทำงานหลักของระบบ แต่บันทึกลง Error Log
error_log("AuditLog Record Failed: " . $e->getMessage());
}
}
/**
* Get recent audit trails by branch or user
*/
public function getRecent(int $branchId = null, int $limit = 100): array
{
$sql = "SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.role
FROM `{$this->table}` a
LEFT JOIN `users` u ON a.user_id = u.id ";
$params = [];
if ($branchId !== null) {
$sql .= " WHERE a.branch_id = :bid ";
$params['bid'] = $branchId;
}
$sql .= " ORDER BY a.id DESC LIMIT :lim";
$stmt = self::getDB()->prepare($sql);
if ($branchId !== null) {
$stmt->bindValue(':bid', $branchId, \PDO::PARAM_INT);
}
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Models;
use PDO;
use PDOException;
/**
* Class Model
* Enterprise Active Record / Data Mapper Base Model (PDO Wrapper with Prepared Statements)
*
* @package App\Models
*/
abstract class Model
{
protected static ?PDO $db = null;
protected string $table;
protected string $primaryKey = 'id';
/**
* Get Singleton PDO Database Connection
*/
public static function getDB(): PDO
{
if (self::$db === null) {
$connectionFile = __DIR__ . '/../../config/connection.php';
if (file_exists($connectionFile)) {
$pdo = require $connectionFile;
if ($pdo instanceof PDO) {
self::$db = $pdo;
return self::$db;
}
}
throw new PDOException("ไม่พบไฟล์เชื่อมต่อฐานข้อมูล config/connection.php หรือไฟล์ไม่ได้ส่งคืน Object PDO");
}
return self::$db;
}
/**
* Find single record by Primary Key
*/
public function find(int $id): ?array
{
$sql = "SELECT * FROM `{$this->table}` WHERE `{$this->primaryKey}` = :id LIMIT 1";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* Get all records with optional limit and order
*/
public function all(int $limit = 100, string $orderBy = 'id DESC'): array
{
$sql = "SELECT * FROM `{$this->table}` ORDER BY {$orderBy} LIMIT :limit";
$stmt = self::getDB()->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Find records by condition (e.g. ['branch_id' => 1, 'status' => 'Waiting'])
*/
public function where(array $conditions, string $orderBy = 'id DESC', int $limit = 50): array
{
$clauses = [];
$params = [];
foreach ($conditions as $col => $val) {
$clauses[] = "`{$col}` = :{$col}";
$params[$col] = $val;
}
$whereSql = implode(' AND ', $clauses);
$sql = "SELECT * FROM `{$this->table}` WHERE {$whereSql} ORDER BY {$orderBy} LIMIT {$limit}";
$stmt = self::getDB()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Find single record by condition
*/
public function firstWhere(array $conditions): ?array
{
$rows = $this->where($conditions, 'id ASC', 1);
return $rows[0] ?? null;
}
/**
* Insert new record and return Insert ID
*/
public function create(array $data): int
{
$cols = array_keys($data);
$fields = implode(', ', array_map(fn($c) => "`{$c}`", $cols));
$placeholders = implode(', ', array_map(fn($c) => ":{$c}", $cols));
$sql = "INSERT INTO `{$this->table}` ({$fields}) VALUES ({$placeholders})";
$stmt = self::getDB()->prepare($sql);
$stmt->execute($data);
return (int)self::getDB()->lastInsertId();
}
/**
* Update record by Primary Key
*/
public function update(int $id, array $data): bool
{
$sets = [];
foreach (array_keys($data) as $col) {
$sets[] = "`{$col}` = :{$col}";
}
$setSql = implode(', ', $sets);
$data['id'] = $id;
$sql = "UPDATE `{$this->table}` SET {$setSql} WHERE `{$this->primaryKey}` = :id";
$stmt = self::getDB()->prepare($sql);
return $stmt->execute($data);
}
/**
* Delete record by Primary Key
*/
public function delete(int $id): bool
{
$sql = "DELETE FROM `{$this->table}` WHERE `{$this->primaryKey}` = :id";
$stmt = self::getDB()->prepare($sql);
return $stmt->execute(['id' => $id]);
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Models;
/**
* Class Patient
* Patient & EMR Model (HIS / HOSxP & Smart Card Integration)
*
* @package App\Models
*/
class Patient extends Model
{
protected string $table = 'patients';
/**
* Find Patient by CID (13 digits National ID)
*/
public function findByCid(string $cid): ?array
{
return $this->firstWhere(['cid' => $cid]);
}
/**
* Find Patient by Hospital Number (HN)
*/
public function findByHn(string $hn): ?array
{
return $this->firstWhere(['hn' => $hn]);
}
/**
* Search patients by Name, CID, or Phone
*/
public function search(string $keyword): array
{
$sql = "SELECT p.*, l.total_points, l.tier
FROM `{$this->table}` p
LEFT JOIN `patient_loyalty` l ON p.id = l.patient_id
WHERE p.cid LIKE :kw OR p.hn LIKE :kw OR p.first_name_th LIKE :kw OR p.last_name_th LIKE :kw OR p.phone LIKE :kw
ORDER BY p.id DESC LIMIT 50";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['kw' => "%{$keyword}%"]);
return $stmt->fetchAll();
}
/**
* Save or Update from Smart Card Reader APDU / WebUSB Payload
*/
public function saveFromSmartCard(array $cardData): int
{
$existing = $this->findByCid($cardData['cid']);
if ($existing) {
$this->update((int)$existing['id'], [
'first_name_th' => $cardData['first_name_th'],
'last_name_th' => $cardData['last_name_th'],
'first_name_en' => $cardData['first_name_en'] ?? null,
'last_name_en' => $cardData['last_name_en'] ?? null,
'birth_date' => $cardData['birth_date'],
'gender' => $cardData['gender'] === '1' || $cardData['gender'] === 'Male' ? 'Male' : 'Female',
'address' => $cardData['address'] ?? null,
'photo_url' => $cardData['photo_url'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
]);
return (int)$existing['id'];
}
// Generate auto HN if not synced yet
$hn = $cardData['hn'] ?? ('HN-' . date('ym') . sprintf('%04d', random_int(1, 9999)));
return $this->create([
'cid' => $cardData['cid'],
'hn' => $hn,
'first_name_th' => $cardData['first_name_th'],
'last_name_th' => $cardData['last_name_th'],
'first_name_en' => $cardData['first_name_en'] ?? null,
'last_name_en' => $cardData['last_name_en'] ?? null,
'birth_date' => $cardData['birth_date'],
'gender' => $cardData['gender'] === '1' || $cardData['gender'] === 'Male' ? 'Male' : 'Female',
'address' => $cardData['address'] ?? null,
'phone' => $cardData['phone'] ?? '080-000-0000',
'photo_url' => $cardData['photo_url'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
/**
* Sync data from HIS / HOSxP REST API
*/
public function updateHisData(int $patientId, array $hisRawData): bool
{
return $this->update($patientId, [
'his_synced_at' => date('Y-m-d H:i:s'),
'his_raw_data' => json_encode($hisRawData, JSON_UNESCAPED_UNICODE),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Models;
/**
* Class Payment
* POS Billing & Cashier Payment Model (PromptPay QR / Receipt / Tax Invoice)
*
* @package App\Models
*/
class Payment extends Model
{
protected string $table = 'payments';
/**
* Generate Receipt Number e.g. REC-202607-0001
*/
public function generateReceiptNo(int $branchId): string
{
$prefix = "REC-" . date('Ym') . "-";
$sql = "SELECT COUNT(*) as cnt FROM `{$this->table}`
WHERE `branch_id` = :bid AND `receipt_no` LIKE :pfx";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId, 'pfx' => "%{$prefix}%"]);
$row = $stmt->fetch();
$nextNum = ((int)($row['cnt'] ?? 0)) + 1;
return sprintf("%s%04d", $prefix, $nextNum);
}
/**
* Generate Tax Invoice Number e.g. TAX-202607-0001
*/
public function generateTaxInvoiceNo(int $branchId): string
{
$prefix = "TAX-" . date('Ym') . "-";
$sql = "SELECT COUNT(*) as cnt FROM `{$this->table}`
WHERE `branch_id` = :bid AND `tax_invoice_no` LIKE :pfx";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId, 'pfx' => "%{$prefix}%"]);
$row = $stmt->fetch();
$nextNum = ((int)($row['cnt'] ?? 0)) + 1;
return sprintf("%s%04d", $prefix, $nextNum);
}
/**
* Process Checkout & Issue Receipt
*/
public function processCheckout(array $data): array
{
$branchId = (int)$data['branch_id'];
$receiptNo = $this->generateReceiptNo($branchId);
$taxNo = !empty($data['request_tax_invoice']) ? $this->generateTaxInvoiceNo($branchId) : null;
$subtotal = (float)$data['subtotal'];
$discount = (float)($data['discount'] ?? 0.00);
$netAmount = max(0.00, $subtotal - $discount);
$method = $data['payment_method'] ?? 'Cash';
$status = 'Paid';
// Generate PromptPay Ref ID if method is PromptPay
$ppRef = ($method === 'PromptPay') ? ('PP-' . date('ymd') . rand(100000, 999999)) : null;
$paymentId = $this->create([
'queue_id' => (int)$data['queue_id'],
'patient_id' => (int)$data['patient_id'],
'branch_id' => $branchId,
'receipt_no' => $receiptNo,
'tax_invoice_no' => $taxNo,
'subtotal' => $subtotal,
'discount' => $discount,
'net_amount' => $netAmount,
'payment_method' => $method,
'payment_status' => $status,
'promptpay_ref' => $ppRef,
'paid_at' => date('Y-m-d H:i:s'),
'cashier_id' => (int)$data['cashier_id'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
// อัปเดตสถานะคิวเป็น Completed เมื่อชำระเงินเรียบร้อย
(new Queue())->update((int)$data['queue_id'], [
'status' => 'Completed',
'actual_end_time' => date('Y-m-d H:i:s'),
]);
return [
'payment_id' => $paymentId,
'receipt_no' => $receiptNo,
'tax_invoice_no' => $taxNo,
'net_amount' => $netAmount,
'payment_method' => $method,
'promptpay_ref' => $ppRef,
'paid_at' => date('Y-m-d H:i:s'),
];
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Models;
use PDO;
/**
* Class Queue
* Core Queue & Smart AI Allocator Model
*
* @package App\Models
*/
class Queue extends Model
{
protected string $table = 'queues';
/**
* Generate Queue Number for today (e.g., A001, V001, E001)
*/
public function generateQueueNo(int $branchId, string $priority = 'Normal'): string
{
$prefix = 'A';
if ($priority === 'VIP') $prefix = 'V';
if ($priority === 'Emergency') $prefix = 'E';
$sql = "SELECT COUNT(*) as cnt FROM `{$this->table}`
WHERE `branch_id` = :bid AND `queue_date` = CURDATE() AND `priority` = :prio";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId, 'prio' => $priority]);
$row = $stmt->fetch();
$nextNum = ((int)($row['cnt'] ?? 0)) + 1;
return sprintf("%s%03d", $prefix, $nextNum);
}
/**
* Create Queue and Execute Smart AI Allocation Stored Procedure
*
* @param array $data
* @return array Result containing queue_id, queue_no, therapist_id, room_id, est_time
*/
public function createWithSmartAssign(array $data): array
{
$branchId = (int)$data['branch_id'];
$priority = $data['priority'] ?? 'Normal';
$queueNo = $this->generateQueueNo($branchId, $priority);
$queueId = $this->create([
'branch_id' => $branchId,
'queue_no' => $queueNo,
'queue_date' => date('Y-m-d'),
'patient_id' => (int)$data['patient_id'],
'therapist_id' => !empty($data['therapist_id']) ? (int)$data['therapist_id'] : null,
'service_id' => (int)$data['service_id'],
'room_id' => !empty($data['room_id']) ? (int)$data['room_id'] : null,
'booking_type' => $data['booking_type'] ?? 'Walk_in',
'priority' => $priority,
'status' => 'Waiting',
'checkin_time' => date('Y-m-d H:i:s'),
'created_by' => (int)$data['created_by'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
// หากผู้ใช้ไม่ได้ระบุหมอนวด ให้ใช้ Smart Queue AI Stored Procedure ทำการเลือกหมอนวดและห้องอัตโนมัติ
$assignedTherapistId = null;
$assignedRoomId = null;
$estStart = null;
$estEnd = null;
$statusMsg = "Created without AI";
if (empty($data['therapist_id'])) {
$stmt = self::getDB()->prepare("CALL sp_assign_smart_queue(:qid, @therapist_id, @room_id, @est_start, @est_end, @status_msg)");
$stmt->execute(['qid' => $queueId]);
$stmt->closeCursor();
$res = self::getDB()->query("SELECT @therapist_id AS t_id, @room_id AS r_id, @est_start AS e_start, @est_end AS e_end, @status_msg AS msg")->fetch();
$assignedTherapistId = $res['t_id'] ? (int)$res['t_id'] : null;
$assignedRoomId = $res['r_id'] ? (int)$res['r_id'] : null;
$estStart = $res['e_start'];
$estEnd = $res['e_end'];
$statusMsg = $res['msg'];
}
return [
'queue_id' => $queueId,
'queue_no' => $queueNo,
'assigned_therapist_id' => $assignedTherapistId,
'assigned_room_id' => $assignedRoomId,
'est_start_time' => $estStart,
'est_end_time' => $estEnd,
'ai_message' => $statusMsg,
];
}
/**
* Get Realtime Queue Board from View
*/
public function getRealtimeBoard(int $branchId = null): array
{
$sql = "SELECT * FROM `vw_realtime_queue_board` ";
$params = [];
if ($branchId !== null) {
$sql .= " WHERE branch_id = :bid ";
$params['bid'] = $branchId;
}
$sql .= " ORDER BY CASE priority WHEN 'Emergency' THEN 1 WHEN 'VIP' THEN 2 ELSE 3 END ASC, checkin_time ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Get TV Mode Display Queues (Waiting vs Calling/In-Progress)
*/
public function getTvDisplay(int $branchId): array
{
$sql = "SELECT q.queue_no, q.status, q.priority,
COALESCE(r.room_no, 'รอเรียกห้อง') AS room_no,
COALESCE(CONCAT(u.first_name, ' ', u.last_name), '-') AS therapist_name
FROM `{$this->table}` q
LEFT JOIN `rooms` r ON q.room_id = r.id
LEFT JOIN `therapists` t ON q.therapist_id = t.id
LEFT JOIN `users` u ON t.user_id = u.id
WHERE q.branch_id = :bid
AND q.queue_date = CURDATE()
AND q.status IN ('Waiting', 'Assigned', 'In_Progress')
ORDER BY q.status DESC, q.checkin_time ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId]);
$all = $stmt->fetchAll();
$waiting = [];
$calling = [];
foreach ($all as $item) {
if (in_array($item['status'], ['Assigned', 'In_Progress'])) {
$calling[] = $item;
} else {
$waiting[] = $item;
}
}
return ['waiting' => $waiting, 'calling' => $calling];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
/**
* Class Room
* Massage Room Realtime Status Model
*
* @package App\Models
*/
class Room extends Model
{
protected string $table = 'rooms';
/**
* Get rooms by branch with current queue and patient info
*/
public function getStatusBoard(int $branchId): array
{
$sql = "SELECT r.*, q.queue_no, q.status AS queue_status,
CONCAT(p.first_name_th, ' ', p.last_name_th) AS patient_name,
CONCAT(u.first_name, ' ', u.last_name) AS therapist_name,
q.est_end_time
FROM `{$this->table}` r
LEFT JOIN `queues` q ON r.current_queue_id = q.id
LEFT JOIN `patients` p ON q.patient_id = p.id
LEFT JOIN `therapists` t ON q.therapist_id = t.id
LEFT JOIN `users` u ON t.user_id = u.id
WHERE r.branch_id = :bid AND r.is_active = 1
ORDER BY r.room_no ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId]);
return $stmt->fetchAll();
}
/**
* Update room status
*/
public function updateStatus(int $roomId, string $status, ?int $queueId = null): bool
{
return $this->update($roomId, [
'status' => $status,
'current_queue_id' => $queueId,
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
/**
* Class Service
* Massage Service & Package Model
*
* @package App\Models
*/
class Service extends Model
{
protected string $table = 'services';
/**
* Get all active services for a branch
*/
public function getActive(int $branchId = null): array
{
$sql = "SELECT * FROM `{$this->table}`
WHERE `is_active` = 1 AND (`branch_id` IS NULL OR `branch_id` = :bid)
ORDER BY `category` ASC, `price` ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['bid' => $branchId ?: 1]);
return $stmt->fetchAll();
}
/**
* Find by Service Code
*/
public function findByCode(string $code): ?array
{
return $this->firstWhere(['code' => $code]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Models;
/**
* Class SoapNote
* Clinical SOAP Note & Assessment Model (VAS Pain Score, ROM, Digital Signature)
*
* @package App\Models
*/
class SoapNote extends Model
{
protected string $table = 'soap_notes';
/**
* Find SOAP Note by Queue ID
*/
public function findByQueueId(int $queueId): ?array
{
return $this->firstWhere(['queue_id' => $queueId]);
}
/**
* Get Clinical History for Patient
*/
public function getPatientHistory(int $patientId, int $limit = 20): array
{
$sql = "SELECT s.*, q.queue_no, q.queue_date,
CONCAT(u.title, u.first_name, ' ', u.last_name) AS therapist_name,
svc.name_th AS service_name
FROM `{$this->table}` s
JOIN `queues` q ON s.queue_id = q.id
LEFT JOIN `therapists` t ON s.therapist_id = t.id
LEFT JOIN `users` u ON t.user_id = u.id
LEFT JOIN `services` svc ON q.service_id = svc.id
WHERE s.patient_id = :pid
ORDER BY q.queue_date DESC, s.id DESC
LIMIT :lim";
$stmt = self::getDB()->prepare($sql);
$stmt->bindValue(':pid', $patientId, \PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Save Clinical Assessment & Sign Consent
*/
public function storeAssessment(array $data): int
{
$existing = $this->findByQueueId((int)$data['queue_id']);
if ($existing) {
$this->update((int)$existing['id'], [
'subjective_symptoms' => $data['subjective_symptoms'] ?? null,
'objective_signs' => $data['objective_signs'] ?? null,
'pre_pain_score' => isset($data['pre_pain_score']) ? (int)$data['pre_pain_score'] : null,
'post_pain_score' => isset($data['post_pain_score']) ? (int)$data['post_pain_score'] : null,
'pre_rom' => $data['pre_rom'] ?? null,
'post_rom' => $data['post_rom'] ?? null,
'assessment_diag' => $data['assessment_diag'] ?? null,
'treatment_plan' => $data['treatment_plan'] ?? null,
'thai_med_outcome' => $data['thai_med_outcome'] ?? 'Stable',
'digital_signature_url' => $data['digital_signature_url'] ?? null,
'patient_consent_signed_at' => !empty($data['digital_signature_url']) ? date('Y-m-d H:i:s') : null,
'updated_at' => date('Y-m-d H:i:s'),
]);
return (int)$existing['id'];
}
return $this->create([
'queue_id' => (int)$data['queue_id'],
'patient_id' => (int)$data['patient_id'],
'therapist_id' => (int)$data['therapist_id'],
'doctor_id' => !empty($data['doctor_id']) ? (int)$data['doctor_id'] : null,
'subjective_symptoms' => $data['subjective_symptoms'] ?? null,
'objective_signs' => $data['objective_signs'] ?? null,
'pre_pain_score' => isset($data['pre_pain_score']) ? (int)$data['pre_pain_score'] : null,
'post_pain_score' => isset($data['post_pain_score']) ? (int)$data['post_pain_score'] : null,
'pre_rom' => $data['pre_rom'] ?? null,
'post_rom' => $data['post_rom'] ?? null,
'assessment_diag' => $data['assessment_diag'] ?? null,
'treatment_plan' => $data['treatment_plan'] ?? null,
'thai_med_outcome' => $data['thai_med_outcome'] ?? 'Stable',
'digital_signature_url' => $data['digital_signature_url'] ?? null,
'patient_consent_signed_at' => !empty($data['digital_signature_url']) ? date('Y-m-d H:i:s') : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
/**
* Class Therapist
* Therapist & Workload Model (Smart Queue Balancer Support)
*
* @package App\Models
*/
class Therapist extends Model
{
protected string $table = 'therapists';
/**
* Get all therapists with User profile details
*/
public function getWithProfiles(int $branchId = null): array
{
$sql = "SELECT t.*, u.national_id, u.title, u.first_name, u.last_name, u.phone, u.email
FROM `{$this->table}` t
JOIN `users` u ON t.user_id = u.id
WHERE t.is_active = 1 ";
$params = [];
if ($branchId !== null) {
$sql .= " AND t.branch_id = :bid ";
$params['bid'] = $branchId;
}
$sql .= " ORDER BY t.current_workload_score ASC, t.id ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Get Available Therapists matching specific skill in a branch
*/
public function getAvailableBySkill(int $branchId, string $skillName): array
{
$sql = "SELECT t.*, u.title, u.first_name, u.last_name
FROM `{$this->table}` t
JOIN `users` u ON t.user_id = u.id
WHERE t.branch_id = :bid
AND t.is_available = 1
AND t.is_active = 1
AND JSON_CONTAINS(t.skills, :skill)
ORDER BY t.current_workload_score ASC";
$stmt = self::getDB()->prepare($sql);
$stmt->execute([
'bid' => $branchId,
'skill' => json_encode($skillName, JSON_UNESCAPED_UNICODE),
]);
return $stmt->fetchAll();
}
/**
* Update Availability Status
*/
public function setAvailability(int $therapistId, bool $available): bool
{
return $this->update($therapistId, ['is_available' => $available ? 1 : 0]);
}
/**
* Get Daily Workload Summary from View
*/
public function getWorkloadSummary(int $branchId = null): array
{
$sql = "SELECT * FROM `vw_therapist_workload_summary`";
$params = [];
if ($branchId !== null) {
$sql .= " WHERE branch_id = :bid";
$params['bid'] = $branchId;
}
$stmt = self::getDB()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Models;
use App\Helpers\Argon2Hasher;
/**
* Class User
* User Model with Argon2id Password Guard & 2FA TOTP Support
*
* @package App\Models
*/
class User extends Model
{
protected string $table = 'users';
/**
* Find user by National ID (13 digits)
*/
public function findByNationalId(string $nationalId): ?array
{
return $this->firstWhere(['national_id' => $nationalId]);
}
/**
* Verify credentials (National ID + Argon2id Password)
*
* @param string $nationalId
* @param string $password
* @return array ['success' => bool, 'user' => ?array, 'error' => ?string]
*/
public function verifyCredentials(string $nationalId, string $password): array
{
$user = $this->findByNationalId($nationalId);
if (!$user) {
return ['success' => false, 'user' => null, 'error' => 'ไม่พบผู้ใช้งานด้วยเลขบัตรประชาชนนี้'];
}
if ((int)$user['is_active'] !== 1) {
return ['success' => false, 'user' => null, 'error' => 'บัญชีนี้ถูกระงับการใช้งาน กรุณาติดต่อผู้ดูแลระบบ'];
}
if (!empty($user['locked_until']) && strtotime($user['locked_until']) > time()) {
$lockMin = ceil((strtotime($user['locked_until']) - time()) / 60);
return ['success' => false, 'user' => null, 'error' => "บัญชีถูกระงับชั่วคราวเนื่องจากกรอกรหัสผ่านผิดเกินกำหนด กรุณารออีก {$lockMin} นาที"];
}
// Verify with Argon2id
if (!Argon2Hasher::verify($password, $user['password_hash'])) {
// Record failed attempt
$this->incrementFailedAttempts((int)$user['id'], (int)$user['failed_login_attempts']);
return ['success' => false, 'user' => null, 'error' => 'รหัสผ่านไม่ถูกต้อง (Argon2id Check Failed)'];
}
// Reset failed attempts & update login IP
$this->recordSuccessfulLogin((int)$user['id']);
// Remove password_hash from returned array for security
unset($user['password_hash']);
return ['success' => true, 'user' => $user, 'error' => null];
}
public function incrementFailedAttempts(int $userId, int $currentAttempts): void
{
$newAttempts = $currentAttempts + 1;
$sql = "UPDATE `{$this->table}` SET `failed_login_attempts` = :att WHERE `id` = :id";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['att' => $newAttempts, 'id' => $userId]);
}
public function recordSuccessfulLogin(int $userId): void
{
$ip = \App\Helpers\Security::getClientIp();
$sql = "UPDATE `{$this->table}` SET `failed_login_attempts` = 0, `locked_until` = NULL, `last_login_at` = NOW(), `last_login_ip` = :ip WHERE `id` = :id";
$stmt = self::getDB()->prepare($sql);
$stmt->execute(['ip' => $ip, 'id' => $userId]);
}
/**
* Enable 2FA Google Authenticator
*/
public function enableTwoFactor(int $userId, string $secret, array $recoveryCodes): bool
{
return $this->update($userId, [
'two_factor_secret' => $secret,
'two_factor_enabled' => 1,
'two_factor_recovery_codes' => json_encode($recoveryCodes),
]);
}
/**
* Disable / Reset 2FA by Admin
*/
public function resetTwoFactor(int $userId): bool
{
return $this->update($userId, [
'two_factor_secret' => null,
'two_factor_enabled' => 0,
'two_factor_recovery_codes' => null,
'remember_token' => null,
]);
}
}
@@ -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()");
}
}