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]);
}
}