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