Healthcare management app: patients, appointments, consultations, lab, pharmacy inventory, billing, and reports at care.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
APP_NAME="Ladill Care"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=https://care.ladill.com
|
||||||
|
|
||||||
|
PLATFORM_URL=https://ladill.com
|
||||||
|
PLATFORM_DOMAIN=ladill.com
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
DB_DATABASE=database/database.sqlite
|
||||||
|
# Production: DB_CONNECTION=mysql, DB_HOST=127.0.0.1, DB_DATABASE=ladill_care, DB_USERNAME=ladill_care
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=1440
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
|
||||||
|
LADILL_SSO_CLIENT_ID=
|
||||||
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Platform APIs this app consumes (per-consumer service keys) ---
|
||||||
|
BILLING_API_URL=https://ladill.com/api/billing
|
||||||
|
BILLING_API_KEY_CARE=
|
||||||
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
|
IDENTITY_API_KEY_CARE=
|
||||||
|
|
||||||
|
# Platform events webhook (user/org lifecycle from the monolith)
|
||||||
|
SERVICE_EVENTS_INBOUND_SECRET=
|
||||||
|
|
||||||
|
# --- Inbound service API keys (sibling Ladill apps calling Care) ---
|
||||||
|
CARE_API_KEY_FRONTDESK=
|
||||||
|
CARE_API_KEY_CRM=
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: Deploy Ladill Care
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-care
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: deploy
|
||||||
|
env:
|
||||||
|
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||||
|
NODE_VERSION: "22.14.0"
|
||||||
|
RELEASE_ARCHIVE: /tmp/ladill-care-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
WORKSPACE: /tmp/${{ gitea.repository_owner }}-care-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
LADILL_APP_ROOT: /var/www/ladill-care
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
|
||||||
|
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
rm -rf "$WORKSPACE"
|
||||||
|
mkdir -p "$WORKSPACE"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
git \
|
||||||
|
-c credential.helper= \
|
||||||
|
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
|
||||||
|
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||||
|
"$REPO_URL" "$WORKSPACE"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||||
|
rm -rf "$NODE_ROOT"
|
||||||
|
mkdir -p "$NODE_ROOT"
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||||
|
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||||
|
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||||
|
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||||
|
fi
|
||||||
|
"$NODE_ROOT/bin/node" -v
|
||||||
|
|
||||||
|
- name: Build frontend assets
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
export PATH="$NODE_ROOT/bin:$PATH"
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build release archive
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
printf '%s\n' "${{ gitea.sha }}" > REVISION
|
||||||
|
rm -f "$RELEASE_ARCHIVE"
|
||||||
|
tar -czf "$RELEASE_ARCHIVE" \
|
||||||
|
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
|
||||||
|
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
|
||||||
|
|
||||||
|
- name: Deploy release
|
||||||
|
shell: bash {0}
|
||||||
|
env:
|
||||||
|
LADILL_RELEASE_ARCHIVE: /tmp/ladill-care-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||||
|
bash "$WORKSPACE/deploy/deploy.sh"
|
||||||
|
|
||||||
|
- name: Ensure nginx vhost
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
|
||||||
|
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||||
|
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||||
|
echo "WARN: setup-service-subdomain-nginx.sh not found — configure care.ladill.com vhost manually"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if sudo -n bash "$NGINX_SCRIPT" care --app /var/www/ladill-care/current; then
|
||||||
|
echo "nginx vhost updated for care.ladill.com"
|
||||||
|
else
|
||||||
|
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash {0}
|
||||||
|
run: |
|
||||||
|
rm -rf "$WORKSPACE"
|
||||||
|
rm -f "$RELEASE_ARCHIVE"
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/storage/framework/views/*
|
||||||
|
!/storage/framework/views/.gitignore
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Native mobile apps — kept locally, not tracked in this repo
|
||||||
|
/apps
|
||||||
|
|
||||||
|
/database/database.sqlite
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Ladill Care — deploy runbook
|
||||||
|
|
||||||
|
Healthcare management at **care.ladill.com** (patients, appointments, consultations, lab, pharmacy, billing).
|
||||||
|
|
||||||
|
## 1. Gitea repo + CI
|
||||||
|
|
||||||
|
- Repo: **ladill-care** (`isaacclad/ladill-care`).
|
||||||
|
- Push to `main` triggers `.gitea/workflows/deploy.yml` (on-host `deploy` runner).
|
||||||
|
- App root: `/var/www/ladill-care`.
|
||||||
|
|
||||||
|
To publish from the monorepo copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/extract-ladill-care.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Server app-slot + database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-care
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-care/{releases,shared}
|
||||||
|
sudo mysql -e "CREATE DATABASE ladill_care CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
sudo mysql -e "CREATE USER 'ladill_care'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||||
|
sudo mysql -e "GRANT ALL ON ladill_care.* TO 'ladill_care'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Platform wiring (monolith)
|
||||||
|
|
||||||
|
On the Ladill platform host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ladill.com/current
|
||||||
|
php artisan ladill:onboard-app care --run-dns
|
||||||
|
php artisan passport:client --name="Ladill Care" --redirect_uri=https://care.ladill.com/sso/callback
|
||||||
|
php artisan ladill:launcher:sync --propagate
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the generated `IDENTITY_API_KEY_CARE`, `BILLING_API_KEY_CARE`,
|
||||||
|
`SERVICE_EVENTS_CARE_*`, and `SERVICE_EVENTS_INBOUND_SECRET` values to the monolith `.env`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Shared `.env` (`/var/www/ladill-care/shared/.env`)
|
||||||
|
|
||||||
|
Copy `.env.example`, set production values:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|----------|-------|
|
||||||
|
| `APP_KEY` | `php artisan key:generate --show` |
|
||||||
|
| `APP_URL` | `https://care.ladill.com` |
|
||||||
|
| `DB_*` | MySQL `ladill_care` credentials |
|
||||||
|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | Passport client (plain text from `passport:client`) |
|
||||||
|
| `BILLING_API_KEY_CARE` | Same as monolith consumer key |
|
||||||
|
| `IDENTITY_API_KEY_CARE` | Same as monolith consumer key |
|
||||||
|
| `SERVICE_EVENTS_INBOUND_SECRET` | Same as monolith `SERVICE_EVENTS_CARE_SECRET` |
|
||||||
|
|
||||||
|
**SSO secret:** use the plain-text value printed once by `passport:client` — never copy
|
||||||
|
`oauth_clients.secret` from the database (Passport stores a bcrypt hash there).
|
||||||
|
|
||||||
|
## 5. nginx + TLS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deployment/setup-service-subdomain-nginx.sh care --app /var/www/ladill-care/current
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS: `care.ladill.com` → app server (via `dns:sync-subdomains` on the monolith).
|
||||||
|
|
||||||
|
## 6. First deploy
|
||||||
|
|
||||||
|
Push to `main` or run the Gitea deploy workflow manually. Then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://care.ladill.com | head -1
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login
|
||||||
|
curl -s https://care.ladill.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Optional queue worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deployment/supervisor/ladill-care-worker.conf /etc/supervisor/conf.d/
|
||||||
|
sudo supervisorctl reread && sudo supervisorctl update
|
||||||
|
```
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Ladill Care
|
||||||
|
|
||||||
|
Healthcare management for clinics and hospitals at **care.ladill.com** — patients,
|
||||||
|
appointments, queue, consultations, vitals, lab, prescriptions, pharmacy inventory,
|
||||||
|
encounter billing, and operational reports.
|
||||||
|
|
||||||
|
Authenticates via **Sign in with Ladill** (OIDC against `auth.ladill.com`). Every
|
||||||
|
clinical record is scoped to a platform account (`owner_ref`) and organization.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Patients** — registration, medical history, documents, visit timeline
|
||||||
|
- **Appointments & queue** — booking, walk-in, check-in, department queue
|
||||||
|
- **Consultations** — vitals, diagnoses, investigations, prescriptions
|
||||||
|
- **Laboratory** — test catalog, sample collection, results, approval
|
||||||
|
- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking
|
||||||
|
- **Billing** — visit invoices, line items, payments, print view
|
||||||
|
- **Reports** — patients, appointments, lab, finance, clinical (CSV export)
|
||||||
|
- **Admin** — branches, departments, practitioners, members, audit log
|
||||||
|
|
||||||
|
## Platform integration
|
||||||
|
|
||||||
|
| Integration | Purpose |
|
||||||
|
|-------------|---------|
|
||||||
|
| OIDC SSO | User authentication (`LADILL_SSO_*`) |
|
||||||
|
| Billing API | Wallet top-up / purchases (`BILLING_API_KEY_CARE`) |
|
||||||
|
| Identity API | Platform user lookups (`IDENTITY_API_KEY_CARE`) |
|
||||||
|
| Service events | Inbound webhooks for `user.deleted`, `user.suspended`, `organization.updated` |
|
||||||
|
|
||||||
|
Registry entry lives in the monolith `config/ladill_apps.php` (`care` slug).
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp .env.example .env && php artisan key:generate
|
||||||
|
touch database/database.sqlite # sqlite is fine for local
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build # or `npm run dev`
|
||||||
|
php artisan serve
|
||||||
|
php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
For SSO locally, point `LADILL_SSO_*` at a Passport client on your Ladill platform
|
||||||
|
dev instance, or bypass `EnsurePlatformSession` in tests (see `CareWebTest`).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See [DEPLOY.md](DEPLOY.md) for production cutover (`/var/www/ladill-care`, Gitea CI,
|
||||||
|
nginx vhost, platform onboarding).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
REST API under `/api/v1` (Sanctum + organization setup middleware). Health check:
|
||||||
|
`GET /api/health`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Contracts\Printers;
|
||||||
|
|
||||||
|
use App\Models\Visit;
|
||||||
|
|
||||||
|
interface PrinterDriverInterface
|
||||||
|
{
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
/** @return array{format: string, content: string, filename?: string} */
|
||||||
|
public function renderBadge(Visit $visit): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A platform domain event received via the inbound service-events webhook.
|
||||||
|
*/
|
||||||
|
class ServiceEventOccurred
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly array $data = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Services\Care\AppointmentService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AppointmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$appointments = $this->appointments->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'practitioner_id', 'date', 'patient_id', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($appointments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$appointment = $this->appointments->book(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedAppointmentData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($appointment, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function walkIn(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$appointment = $this->appointments->walkIn(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedWalkInData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($appointment, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Appointment $appointment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
return response()->json($appointment->load(['patient', 'practitioner', 'branch', 'visit', 'consultation']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIn(Request $request, Appointment $appointment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$updated = $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Appointment $appointment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$updated = $this->appointments->cancel($appointment, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAppointment(Request $request, Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $appointment);
|
||||||
|
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null && $appointment->branch_id !== $branchScope) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedAppointmentData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
|
||||||
|
'scheduled_at' => ['required', 'date', 'after:now'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedWalkInData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\BillService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BillController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected BillService $bills,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$bills = $this->bills->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($bills);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(Request $request, Visit $visit): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.manage');
|
||||||
|
$this->authorizeVisit($request, $visit);
|
||||||
|
|
||||||
|
$bill = $this->bills->generateFromVisit($visit, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json($bill, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Bill $bill): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.view');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
return response()->json($bill->load(['patient', 'lineItems', 'payments']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordPayment(Request $request, Bill $bill): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'payments.manage');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$payment = $this->bills->recordPayment(
|
||||||
|
$bill,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->validate([
|
||||||
|
'amount_minor' => ['required', 'integer', 'min:1'],
|
||||||
|
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
|
||||||
|
'reference' => ['nullable', 'string', 'max:100'],
|
||||||
|
]),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($payment, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeBill(Request $request, Bill $bill): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $bill);
|
||||||
|
abort_unless($bill->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeVisit(Request $request, Visit $visit): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $visit);
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
trait ScopesApiToAccount
|
||||||
|
{
|
||||||
|
protected function ownerRef(Request $request): string
|
||||||
|
{
|
||||||
|
return (string) $request->user()->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function organization(Request $request): Organization
|
||||||
|
{
|
||||||
|
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
|
||||||
|
abort_unless($organization, 404);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function member(Request $request): ?Member
|
||||||
|
{
|
||||||
|
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAbility(Request $request, string $ability): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
app(CarePermissions::class)->can($this->member($request), $ability),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeOwner(Request $request, Model $model): void
|
||||||
|
{
|
||||||
|
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Services\Care\AppointmentService;
|
||||||
|
use App\Services\Care\ConsultationService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ConsultationController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ConsultationService $consultations,
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
return response()->json($consultation->load([
|
||||||
|
'patient', 'practitioner', 'visit', 'appointment',
|
||||||
|
'vitalSigns', 'diagnoses', 'documents',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, Appointment $appointment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$practitionerId = $request->input('practitioner_id')
|
||||||
|
? (int) $request->input('practitioner_id')
|
||||||
|
: $appointment->practitioner_id;
|
||||||
|
|
||||||
|
$this->appointments->startConsultation(
|
||||||
|
$appointment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$practitionerId,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
$consultation = $this->consultations->startFromAppointment(
|
||||||
|
$appointment->fresh(),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($consultation, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$updated = $this->consultations->save(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedConsultationData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$completed = $this->consultations->complete(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAppointment(Request $request, Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $appointment);
|
||||||
|
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null && $appointment->branch_id !== $branchScope) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null && $consultation->visit->branch_id !== $branchScope) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedConsultationData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'symptoms' => ['nullable', 'string', 'max:10000'],
|
||||||
|
'clinical_notes' => ['nullable', 'string', 'max:20000'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'vitals' => ['nullable', 'array'],
|
||||||
|
'vitals.bp_systolic' => ['nullable', 'integer', 'min:50', 'max:300'],
|
||||||
|
'vitals.bp_diastolic' => ['nullable', 'integer', 'min:30', 'max:200'],
|
||||||
|
'vitals.pulse' => ['nullable', 'integer', 'min:20', 'max:250'],
|
||||||
|
'vitals.temperature' => ['nullable', 'numeric', 'min:30', 'max:45'],
|
||||||
|
'vitals.weight_kg' => ['nullable', 'numeric', 'min:0', 'max:500'],
|
||||||
|
'vitals.height_cm' => ['nullable', 'numeric', 'min:0', 'max:300'],
|
||||||
|
'vitals.spo2' => ['nullable', 'integer', 'min:50', 'max:100'],
|
||||||
|
'vitals.respiratory_rate' => ['nullable', 'integer', 'min:5', 'max:80'],
|
||||||
|
'diagnoses' => ['nullable', 'array'],
|
||||||
|
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
|
||||||
|
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Drug;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Services\Care\PharmacyService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DrugController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PharmacyService $pharmacy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$drugs = $this->pharmacy->listDrugs(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['q', 'per_page']),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($drugs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
|
||||||
|
$drug = $this->pharmacy->createDrug(
|
||||||
|
$this->organization($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'generic_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'sku' => ['nullable', 'string', 'max:100'],
|
||||||
|
'unit_price_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($drug, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispense(Request $request, Prescription $prescription): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.dispense');
|
||||||
|
$this->authorizeOwner($request, $prescription);
|
||||||
|
|
||||||
|
$updated = $this->pharmacy->dispensePrescription(
|
||||||
|
$prescription,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->input('allocations', []),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\InvestigationRequest;
|
||||||
|
use App\Models\InvestigationType;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Services\Care\InvestigationService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PrescriptionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class InvestigationController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected InvestigationService $investigations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function catalog(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$types = InvestigationType::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $types]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$requests = $this->investigations->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queue(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'status' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue = $this->investigations->workQueue(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
(int) $validated['branch_id'],
|
||||||
|
$validated['status'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['data' => $queue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'investigations.request');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'investigation_type_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'investigation_type_ids.*' => ['integer', 'exists:care_investigation_types,id'],
|
||||||
|
'clinical_notes' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'priority' => ['nullable', 'string', 'in:routine,urgent'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created = $this->investigations->requestFromConsultation(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['investigation_type_ids'],
|
||||||
|
$validated['clinical_notes'] ?? null,
|
||||||
|
$validated['priority'] ?? 'routine',
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['data' => $created], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, InvestigationRequest $investigation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.view');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
return response()->json($investigation->load([
|
||||||
|
'patient', 'investigationType', 'result.values', 'result.attachments',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectSample(Request $request, InvestigationRequest $investigation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$updated = $this->investigations->collectSample(
|
||||||
|
$investigation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->input('sample_barcode'),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enterResults(Request $request, InvestigationRequest $investigation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'value' => ['nullable', 'string', 'max:255'],
|
||||||
|
'result_summary' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'interpretation' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'values' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->investigations->enterResults(
|
||||||
|
$investigation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(Request $request, InvestigationRequest $investigation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$updated = $this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeInvestigation(Request $request, InvestigationRequest $investigation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $investigation);
|
||||||
|
abort_unless($investigation->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null && $investigation->branch_id !== $branchScope) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PatientService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PatientController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PatientService $patients,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$patients = $this->patients->search(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($patients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$patient = $this->patients->create(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedPatientData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($patient, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
return response()->json($this->patients->dashboard($patient));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$updated = $this->patients->update(
|
||||||
|
$patient,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedPatientData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Patient $patient): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$this->patients->delete($patient, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Patient archived.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null && $patient->branch_id !== $branchScope) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedPatientData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
||||||
|
'first_name' => ['required', 'string', 'max:100'],
|
||||||
|
'last_name' => ['required', 'string', 'max:100'],
|
||||||
|
'other_names' => ['nullable', 'string', 'max:100'],
|
||||||
|
'gender' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.genders')))],
|
||||||
|
'date_of_birth' => ['nullable', 'date', 'before:today'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'national_id' => ['nullable', 'string', 'max:50'],
|
||||||
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'region' => ['nullable', 'string', 'max:100'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'allergies' => ['nullable', 'array'],
|
||||||
|
'conditions' => ['nullable', 'array'],
|
||||||
|
'family_history' => ['nullable', 'array'],
|
||||||
|
'emergency_contacts' => ['nullable', 'array'],
|
||||||
|
'insurance' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PrescriptionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PrescriptionController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PrescriptionService $prescriptions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$prescriptions = $this->prescriptions->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id', 'per_page']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($prescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queue(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$queue = $this->prescriptions->pharmacyQueue($this->ownerRef($request), $organization->id);
|
||||||
|
|
||||||
|
return response()->json(['data' => $queue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Consultation $consultation): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$prescription = $this->prescriptions->createFromConsultation(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedPrescriptionData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($prescription, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Prescription $prescription): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
return response()->json($prescription->load(['patient', 'practitioner', 'items']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispense(Request $request, Prescription $prescription): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.dispense');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
$updated = $this->prescriptions->dispense($prescription, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePrescription(Request $request, Prescription $prescription): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $prescription);
|
||||||
|
abort_unless($prescription->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedPrescriptionData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'activate' => ['nullable', 'boolean'],
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.is_procedure' => ['nullable', 'boolean'],
|
||||||
|
'items.*.name' => ['required', 'string', 'max:255'],
|
||||||
|
'items.*.dosage' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.frequency' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.duration' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.route' => ['nullable', 'string', 'max:50'],
|
||||||
|
'items.*.quantity' => ['nullable', 'string', 'max:50'],
|
||||||
|
'items.*.instructions' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||||
|
use App\Services\Care\AppointmentService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class QueueController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesApiToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queue = $this->appointments->queue(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
(int) $validated['branch_id'],
|
||||||
|
isset($validated['practitioner_id']) ? (int) $validated['practitioner_id'] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['data' => $queue]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Events\ServiceEventSignature;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class ServiceEventController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$secret = (string) config('service_events.inbound_secret');
|
||||||
|
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||||
|
|
||||||
|
if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) {
|
||||||
|
return response()->json(['error' => 'invalid signature'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', ''));
|
||||||
|
if ($eventId !== '') {
|
||||||
|
if (Cache::has("svcevt:{$eventId}")) {
|
||||||
|
return response()->json(['status' => 'duplicate']);
|
||||||
|
}
|
||||||
|
Cache::put("svcevt:{$eventId}", true, now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', [])));
|
||||||
|
|
||||||
|
return response()->json(['status' => 'accepted']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||||
|
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||||
|
*/
|
||||||
|
class SsoLoginController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Max consecutive failed callbacks before we stop bouncing back into the
|
||||||
|
* authorize flow and show an error page instead (prevents an infinite
|
||||||
|
* /sso/callback ↔ /sso/connect redirect loop on a persistent upstream error).
|
||||||
|
*/
|
||||||
|
private const MAX_SSO_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
public function connect(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
$intended = (string) $request->query('redirect', route('care.dashboard'));
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
return $this->safeRedirect($intended, route('care.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->boolean('fallback')) {
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||||
|
return $this->safeRedirect($intended, route('care.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifier = Str::random(64);
|
||||||
|
$state = Str::random(40);
|
||||||
|
$request->session()->put('sso.verifier', $verifier);
|
||||||
|
$request->session()->put('sso.state', $state);
|
||||||
|
$request->session()->put('sso.intended', $intended);
|
||||||
|
|
||||||
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||||
|
'scope' => 'openid profile email',
|
||||||
|
'state' => $state,
|
||||||
|
'code_challenge' => $challenge,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
];
|
||||||
|
|
||||||
|
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||||
|
if ($loginHint !== '') {
|
||||||
|
$query['login_hint'] = $loginHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->boolean('interactive')) {
|
||||||
|
$query['prompt'] = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||||
|
|
||||||
|
return redirect()->away($authorizeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$intended = (string) $request->session()->get('sso.intended', route('care.dashboard'));
|
||||||
|
|
||||||
|
if ($request->filled('error')) {
|
||||||
|
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||||
|
&& ! $request->boolean('interactive')) {
|
||||||
|
return redirect()->away((string) config('ladill.marketing_url'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->filled('code')
|
||||||
|
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||||
|
return $this->finishCallback($request, $intended, 'invalid_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
|
||||||
|
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||||
|
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||||
|
'code' => (string) $request->query('code'),
|
||||||
|
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||||
|
]);
|
||||||
|
if ($tokenRes->failed()) {
|
||||||
|
return $this->finishCallback($request, $intended, 'token_exchange_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||||
|
if (! $user) {
|
||||||
|
return $this->finishCallback($request, $intended, 'userinfo_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
|
||||||
|
return $this->finishCallback($request, $intended, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(Request $request): View
|
||||||
|
{
|
||||||
|
return view('auth.sso-error', [
|
||||||
|
'reason' => (string) $request->session()->get('sso.error', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
// Per-app sign-out: end only this app's session and keep the platform
|
||||||
|
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
|
||||||
|
return redirect()->route('care.signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||||
|
public function platformSignedOut(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => (string) $request->query('redirect', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoutBridge(Request $request): View
|
||||||
|
{
|
||||||
|
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||||
|
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||||
|
'embedded' => 1,
|
||||||
|
'return' => $return,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('auth.sso-logout-bridge', [
|
||||||
|
'hubUrl' => $hubUrl,
|
||||||
|
'return' => $return,
|
||||||
|
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
$return = (string) $request->query('return', '');
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
$host = parse_url($return, PHP_URL_HOST);
|
||||||
|
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return redirect()->away($return);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||||
|
{
|
||||||
|
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||||
|
if ($refreshToken === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||||
|
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||||
|
'scope' => 'openid profile email',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tokenRes->failed()) {
|
||||||
|
$request->session()->forget('sso.refresh_token');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
$request->session()->put('sso.intended', $intended);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||||
|
{
|
||||||
|
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||||
|
if ($refreshToken !== '') {
|
||||||
|
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||||
|
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||||
|
if ($claims->failed() || ! $claims->json('sub')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = (string) ($claims->json('email') ?: '');
|
||||||
|
if ($email !== '') {
|
||||||
|
$request->session()->put('sso.login_hint', $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::updateOrCreate(
|
||||||
|
['public_id' => (string) $claims->json('sub')],
|
||||||
|
[
|
||||||
|
'name' => $claims->json('name'),
|
||||||
|
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||||
|
'avatar_url' => $claims->json('picture'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($error) {
|
||||||
|
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
||||||
|
|
||||||
|
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
||||||
|
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
||||||
|
$request->session()->flash('sso.error', $error);
|
||||||
|
|
||||||
|
return redirect()->route('sso.failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('sso.attempts', $attempts);
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $intended,
|
||||||
|
'interactive' => 1,
|
||||||
|
'fallback' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->safeRedirect($intended, route('care.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeReturnUrl(string $url): string
|
||||||
|
{
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
|
if (is_string($host) && str_starts_with($url, 'https://')
|
||||||
|
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('care.signed-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||||
|
{
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
|
if (is_string($host) && str_starts_with($url, 'https://')
|
||||||
|
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||||
|
return redirect()->away($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Practitioner;
|
||||||
|
use App\Services\Care\AppointmentService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class AppointmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$appointments = $this->appointments->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'practitioner_id', 'date', 'patient_id']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
$practitioners = $this->activePractitioners($request, $organization->id);
|
||||||
|
|
||||||
|
return view('care.appointments.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'appointments' => $appointments,
|
||||||
|
'practitioners' => $practitioners,
|
||||||
|
'statuses' => config('care.appointment_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
return view('care.appointments.create', $this->formData($request, $organization));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$validated = $this->validatedAppointmentData($request);
|
||||||
|
|
||||||
|
$appointment = $this->appointments->book(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.appointments.show', $appointment)
|
||||||
|
->with('success', 'Appointment booked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function walkInCreate(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
return view('care.appointments.walk-in', $this->formData($request, $organization));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function walkInStore(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$validated = $this->validatedWalkInData($request);
|
||||||
|
|
||||||
|
$appointment = $this->appointments->walkIn(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.queue.index')
|
||||||
|
->with('success', 'Walk-in added to queue.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Appointment $appointment): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$appointment->load(['patient', 'practitioner', 'branch', 'department', 'visit', 'consultation']);
|
||||||
|
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'appointments.manage');
|
||||||
|
$canConsult = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'consultations.manage');
|
||||||
|
|
||||||
|
return view('care.appointments.show', [
|
||||||
|
'appointment' => $appointment,
|
||||||
|
'statuses' => config('care.appointment_statuses'),
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canConsult' => $canConsult,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIn(Request $request, Appointment $appointment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.queue.index')
|
||||||
|
->with('success', 'Patient checked in and added to queue.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Appointment $appointment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$this->appointments->cancel($appointment, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Appointment cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function noShow(Request $request, Appointment $appointment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$this->appointments->markNoShow($appointment, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Marked as no-show.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAppointment(Request $request, Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $appointment);
|
||||||
|
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $appointment->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function formData(Request $request, \App\Models\Organization $organization): array
|
||||||
|
{
|
||||||
|
$ownerRef = $this->ownerRef($request);
|
||||||
|
$branchQuery = Branch::owned($ownerRef)->where('organization_id', $organization->id)->where('is_active', true);
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchScope !== null) {
|
||||||
|
$branchQuery->where('id', $branchScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
$branches = $branchQuery->orderBy('name')->get();
|
||||||
|
$defaultBranch = $branchScope ?? $branches->first()?->id;
|
||||||
|
|
||||||
|
$patients = Patient::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||||
|
->orderBy('first_name')
|
||||||
|
->limit(200)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$departments = Department::owned($ownerRef)
|
||||||
|
->whereIn('branch_id', $branches->pluck('id'))
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'defaultBranch' => $defaultBranch,
|
||||||
|
'patients' => $patients,
|
||||||
|
'practitioners' => $this->activePractitioners($request, $organization->id),
|
||||||
|
'departments' => $departments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int, Practitioner>
|
||||||
|
*/
|
||||||
|
protected function activePractitioners(Request $request, int $organizationId)
|
||||||
|
{
|
||||||
|
return Practitioner::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedAppointmentData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
|
||||||
|
'scheduled_at' => ['required', 'date', 'after:now'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedWalkInData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class AuditLogController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'audit.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
|
||||||
|
|
||||||
|
return view('care.audit.index', [
|
||||||
|
'logs' => $logs,
|
||||||
|
'organization' => $organization,
|
||||||
|
'actions' => config('care.audit_actions'),
|
||||||
|
'canExport' => app(CarePermissions::class)->can($this->member($request), 'audit.export'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(Request $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'audit.export');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$filename = 'care-audit-'.now()->format('Y-m-d-His').'.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($request, $organization) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
|
||||||
|
|
||||||
|
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$log->created_at?->toDateTimeString(),
|
||||||
|
$log->action,
|
||||||
|
$log->actor_ref ?? '—',
|
||||||
|
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
|
||||||
|
json_encode($log->metadata ?? []),
|
||||||
|
$log->ip_address ?? '—',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function query(Request $request, $organization)
|
||||||
|
{
|
||||||
|
return AuditLog::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->when($request->action, fn ($q, $action) => $q->where('action', $action))
|
||||||
|
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
|
||||||
|
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
|
||||||
|
->when($request->q, function ($q, $search) {
|
||||||
|
$q->where(function ($inner) use ($search) {
|
||||||
|
$inner->where('action', 'like', "%{$search}%")
|
||||||
|
->orWhere('metadata', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use App\Services\Care\BillService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class BillController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected BillService $bills,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$bills = $this->bills->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.bills.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'bills' => $bills,
|
||||||
|
'statuses' => config('care.bill_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(Request $request, Visit $visit): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.manage');
|
||||||
|
$this->authorizeVisit($request, $visit);
|
||||||
|
|
||||||
|
$bill = $this->bills->generateFromVisit($visit, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.bills.show', $bill)
|
||||||
|
->with('success', 'Bill generated from visit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Bill $bill): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.view');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$bill->load(['patient', 'branch', 'visit', 'lineItems', 'payments']);
|
||||||
|
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'bills.manage');
|
||||||
|
$canPay = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'payments.manage');
|
||||||
|
|
||||||
|
return view('care.bills.show', [
|
||||||
|
'bill' => $bill,
|
||||||
|
'statuses' => config('care.bill_statuses'),
|
||||||
|
'lineTypes' => config('care.bill_line_types'),
|
||||||
|
'paymentMethods' => config('care.payment_methods'),
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canPay' => $canPay,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addLineItem(Request $request, Bill $bill): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.manage');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$this->bills->addManualLineItem(
|
||||||
|
$bill,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->validate([
|
||||||
|
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.bill_line_types')))],
|
||||||
|
'description' => ['required', 'string', 'max:255'],
|
||||||
|
'quantity' => ['required', 'integer', 'min:1'],
|
||||||
|
'unit_price_minor' => ['required', 'integer', 'min:0'],
|
||||||
|
]),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Line item added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyAdjustments(Request $request, Bill $bill): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.manage');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'discount_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'tax_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bills->applyAdjustments(
|
||||||
|
$bill,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
(int) ($validated['discount_minor'] ?? 0),
|
||||||
|
(int) ($validated['tax_minor'] ?? 0),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Bill updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordPayment(Request $request, Bill $bill): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'payments.manage');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$this->bills->recordPayment(
|
||||||
|
$bill,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->validate([
|
||||||
|
'amount_minor' => ['required', 'integer', 'min:1'],
|
||||||
|
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
|
||||||
|
'reference' => ['nullable', 'string', 'max:100'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:500'],
|
||||||
|
]),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Payment recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(Request $request, Bill $bill): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.manage');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$this->bills->void($bill, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.bills.index')->with('success', 'Bill voided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function print(Request $request, Bill $bill): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'bills.view');
|
||||||
|
$this->authorizeBill($request, $bill);
|
||||||
|
|
||||||
|
$bill->load(['patient', 'branch', 'lineItems', 'payments', 'organization']);
|
||||||
|
|
||||||
|
return view('care.bills.print', [
|
||||||
|
'bill' => $bill,
|
||||||
|
'organization' => $this->organization($request),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeBill(Request $request, Bill $bill): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $bill);
|
||||||
|
abort_unless($bill->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $bill->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeVisit(Request $request, Visit $visit): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $visit);
|
||||||
|
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Services\Care\AuditLogger;
|
||||||
|
use App\Services\Care\PlanService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class BranchController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->withCount('departments')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.admin.branches.index', compact('branches', 'organization'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||||
|
|
||||||
|
return view('care.admin.branches.create', ['organization' => $this->organization($request)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$currentCount = Branch::owned($owner)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if (! app(PlanService::class)->canAddBranch($organization, $currentCount)) {
|
||||||
|
return back()->withInput()->with('error', 'Your plan allows one branch. Upgrade to Care Pro for unlimited branches.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branch = Branch::create([
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
...$validated,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'branch.created', $organization->id, $owner, Branch::class, $branch->id);
|
||||||
|
|
||||||
|
return redirect()->route('care.branches.index')->with('success', 'Branch created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Branch $branch): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||||
|
$this->authorizeOwner($request, $branch);
|
||||||
|
|
||||||
|
return view('care.admin.branches.edit', compact('branch'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Branch $branch): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||||
|
$this->authorizeOwner($request, $branch);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branch->update([
|
||||||
|
...$validated,
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
'branch.updated',
|
||||||
|
$branch->organization_id,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
Branch::class,
|
||||||
|
$branch->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.branches.index')->with('success', 'Branch updated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
trait ScopesToAccount
|
||||||
|
{
|
||||||
|
protected function ownerRef(Request $request): string
|
||||||
|
{
|
||||||
|
return (string) $request->user()->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function organization(Request $request): Organization
|
||||||
|
{
|
||||||
|
$organization = $request->attributes->get('care.organization')
|
||||||
|
?? app(OrganizationResolver::class)->resolveForUser($request->user());
|
||||||
|
|
||||||
|
abort_unless($organization, 404);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function member(Request $request): ?Member
|
||||||
|
{
|
||||||
|
return $request->attributes->get('care.member')
|
||||||
|
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAbility(Request $request, string $ability): void
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
app(CarePermissions::class)->can($this->member($request), $ability),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeOwner(Request $request, Model $model): void
|
||||||
|
{
|
||||||
|
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
|
||||||
|
{
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where($column, $branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\InvestigationType;
|
||||||
|
use App\Models\Practitioner;
|
||||||
|
use App\Services\Care\ConsultationService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ConsultationController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ConsultationService $consultations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request, Consultation $consultation): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.view');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$consultation->load([
|
||||||
|
'patient', 'practitioner', 'visit', 'appointment',
|
||||||
|
'vitalSigns', 'diagnoses', 'documents',
|
||||||
|
'investigationRequests.investigationType', 'prescriptions.items',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'consultations.manage');
|
||||||
|
$canVitals = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'vitals.manage');
|
||||||
|
$canRequestInvestigations = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'investigations.request');
|
||||||
|
$canPrescribe = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'prescriptions.manage');
|
||||||
|
$canGenerateBill = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'bills.manage');
|
||||||
|
|
||||||
|
$practitioners = Practitioner::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $this->organization($request)->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $this->organization($request)->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.consultations.show', [
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'practitioners' => $practitioners,
|
||||||
|
'investigationTypes' => $investigationTypes,
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canVitals' => $canVitals,
|
||||||
|
'canRequestInvestigations' => $canRequestInvestigations,
|
||||||
|
'canPrescribe' => $canPrescribe,
|
||||||
|
'canGenerateBill' => $canGenerateBill,
|
||||||
|
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
{
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'consultations.manage');
|
||||||
|
$canVitals = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'vitals.manage');
|
||||||
|
|
||||||
|
abort_unless($canManage || $canVitals, 403);
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
abort_if($consultation->status === Consultation::STATUS_COMPLETED, 422);
|
||||||
|
|
||||||
|
$validated = $this->validatedConsultationData($request, $canManage, $canVitals);
|
||||||
|
|
||||||
|
$this->consultations->save(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Consultation saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$this->consultations->complete(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.patients.show', $consultation->patient)
|
||||||
|
->with('success', 'Consultation completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedConsultationData(Request $request, bool $canManage, bool $canVitals): array
|
||||||
|
{
|
||||||
|
$rules = [];
|
||||||
|
|
||||||
|
if ($canManage) {
|
||||||
|
$rules = array_merge($rules, [
|
||||||
|
'symptoms' => ['nullable', 'string', 'max:10000'],
|
||||||
|
'clinical_notes' => ['nullable', 'string', 'max:20000'],
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'diagnoses' => ['nullable', 'array'],
|
||||||
|
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
|
||||||
|
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'documents' => ['nullable', 'array'],
|
||||||
|
'documents.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
|
||||||
|
]);
|
||||||
|
$canVitals = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canVitals) {
|
||||||
|
$rules['vitals'] = ['nullable', 'array'];
|
||||||
|
$rules['vitals.bp_systolic'] = ['nullable', 'integer', 'min:50', 'max:300'];
|
||||||
|
$rules['vitals.bp_diastolic'] = ['nullable', 'integer', 'min:30', 'max:200'];
|
||||||
|
$rules['vitals.pulse'] = ['nullable', 'integer', 'min:20', 'max:250'];
|
||||||
|
$rules['vitals.temperature'] = ['nullable', 'numeric', 'min:30', 'max:45'];
|
||||||
|
$rules['vitals.weight_kg'] = ['nullable', 'numeric', 'min:0', 'max:500'];
|
||||||
|
$rules['vitals.height_cm'] = ['nullable', 'numeric', 'min:0', 'max:300'];
|
||||||
|
$rules['vitals.spo2'] = ['nullable', 'integer', 'min:50', 'max:100'];
|
||||||
|
$rules['vitals.respiratory_rate'] = ['nullable', 'integer', 'min:5', 'max:80'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->validate($rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\ReportService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ReportService $reports,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'dashboard.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$branchQuery = Branch::owned($owner)->where('organization_id', $organization->id);
|
||||||
|
$this->scopeToBranch($request, $branchQuery);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'branches' => (clone $branchQuery)->where('is_active', true)->count(),
|
||||||
|
'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(),
|
||||||
|
'departments' => $organization->branches()
|
||||||
|
->when(app(OrganizationResolver::class)->branchScope($this->member($request)), function ($q, $branchId) {
|
||||||
|
$q->where('id', $branchId);
|
||||||
|
})
|
||||||
|
->withCount('departments')
|
||||||
|
->get()
|
||||||
|
->sum('departments_count'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$branches = (clone $branchQuery)->withCount('departments')->orderBy('name')->get();
|
||||||
|
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$operational = $this->reports->dashboardStats($owner, $organization->id, $branchScope);
|
||||||
|
|
||||||
|
return view('care.dashboard', compact('organization', 'stats', 'branches', 'operational'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Services\Care\AuditLogger;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DepartmentController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$departments = Department::owned($owner)
|
||||||
|
->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id))
|
||||||
|
->with('branch')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.admin.departments.index', [
|
||||||
|
'departments' => $departments,
|
||||||
|
'organization' => $organization,
|
||||||
|
'types' => config('care.department_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.admin.departments.create', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'types' => config('care.department_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.department_types')))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branch = Branch::owned($owner)->findOrFail($validated['branch_id']);
|
||||||
|
abort_unless($branch->organization_id === $organization->id, 404);
|
||||||
|
|
||||||
|
$department = Department::create([
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'branch_id' => $branch->id,
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'type' => $validated['type'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id);
|
||||||
|
|
||||||
|
return redirect()->route('care.departments.index')->with('success', 'Department created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Department $department): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.manage');
|
||||||
|
$this->authorizeOwner($request, $department);
|
||||||
|
|
||||||
|
return view('care.admin.departments.edit', [
|
||||||
|
'department' => $department,
|
||||||
|
'types' => config('care.department_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Department $department): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.manage');
|
||||||
|
$this->authorizeOwner($request, $department);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.department_types')))],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$department->update([
|
||||||
|
...$validated,
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
'department.updated',
|
||||||
|
$department->branch->organization_id,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
Department::class,
|
||||||
|
$department->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.departments.index')->with('success', 'Department updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Department $department): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.departments.manage');
|
||||||
|
$this->authorizeOwner($request, $department);
|
||||||
|
|
||||||
|
$department->load('branch');
|
||||||
|
$organizationId = $department->branch->organization_id;
|
||||||
|
$departmentId = $department->id;
|
||||||
|
|
||||||
|
$department->delete();
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
'department.deleted',
|
||||||
|
$organizationId,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
Department::class,
|
||||||
|
$departmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.departments.index')->with('success', 'Department removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Drug;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PharmacyService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DrugController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PharmacyService $pharmacy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$drugs = $this->pharmacy->listDrugs(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['q']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
$lowStock = $this->pharmacy->lowStock($this->ownerRef($request), $organization->id);
|
||||||
|
$expired = $this->pharmacy->expiredBatches($this->ownerRef($request), $organization->id);
|
||||||
|
|
||||||
|
return view('care.pharmacy.drugs.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'drugs' => $drugs,
|
||||||
|
'lowStock' => $lowStock,
|
||||||
|
'expired' => $expired,
|
||||||
|
'canManage' => app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'pharmacy.manage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
|
||||||
|
return view('care.pharmacy.drugs.create', [
|
||||||
|
'organization' => $this->organization($request),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
|
||||||
|
$drug = $this->pharmacy->createDrug(
|
||||||
|
$this->organization($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedDrugData($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.pharmacy.drugs.show', $drug)
|
||||||
|
->with('success', 'Drug added to inventory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Drug $drug): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.view');
|
||||||
|
$this->authorizeDrug($request, $drug);
|
||||||
|
|
||||||
|
$drug->load(['batches' => fn ($q) => $q->orderBy('expiry_date')]);
|
||||||
|
|
||||||
|
return view('care.pharmacy.drugs.show', [
|
||||||
|
'drug' => $drug,
|
||||||
|
'canManage' => app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'pharmacy.manage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Drug $drug): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
$this->authorizeDrug($request, $drug);
|
||||||
|
|
||||||
|
return view('care.pharmacy.drugs.edit', ['drug' => $drug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Drug $drug): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
$this->authorizeDrug($request, $drug);
|
||||||
|
|
||||||
|
$this->pharmacy->updateDrug($drug, $this->ownerRef($request), $this->validatedDrugData($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.pharmacy.drugs.show', $drug)
|
||||||
|
->with('success', 'Drug updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiveBatch(Request $request, Drug $drug): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'pharmacy.manage');
|
||||||
|
$this->authorizeDrug($request, $drug);
|
||||||
|
|
||||||
|
$this->pharmacy->receiveBatch(
|
||||||
|
$drug,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$request->validate([
|
||||||
|
'batch_number' => ['required', 'string', 'max:100'],
|
||||||
|
'expiry_date' => ['nullable', 'date', 'after:today'],
|
||||||
|
'quantity_on_hand' => ['required', 'integer', 'min:1'],
|
||||||
|
'cost_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
]),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Stock received.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeDrug(Request $request, Drug $drug): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $drug);
|
||||||
|
abort_unless($drug->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedDrugData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'generic_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'sku' => ['nullable', 'string', 'max:100'],
|
||||||
|
'unit' => ['nullable', 'string', 'max:50'],
|
||||||
|
'unit_price_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reorder_level' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\InvestigationRequest;
|
||||||
|
use App\Models\InvestigationType;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Services\Care\InvestigationService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class InvestigationController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected InvestigationService $investigations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$requests = $this->investigations->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.lab.requests.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'requests' => $requests,
|
||||||
|
'statuses' => config('care.investigation_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queue(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->when($branchScope, fn ($q) => $q->where('id', $branchScope))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id);
|
||||||
|
$status = $request->input('status');
|
||||||
|
|
||||||
|
$queue = $branchId
|
||||||
|
? $this->investigations->workQueue($this->ownerRef($request), $branchId, $status)
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return view('care.lab.queue.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'branchId' => $branchId,
|
||||||
|
'status' => $status,
|
||||||
|
'queue' => $queue,
|
||||||
|
'statuses' => config('care.investigation_statuses'),
|
||||||
|
'members' => Member::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->orderBy('role')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestFromConsultation(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'investigations.request');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'investigation_type_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'investigation_type_ids.*' => ['integer', 'exists:care_investigation_types,id'],
|
||||||
|
'clinical_notes' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'priority' => ['nullable', 'string', 'in:routine,urgent'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->investigations->requestFromConsultation(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['investigation_type_ids'],
|
||||||
|
$validated['clinical_notes'] ?? null,
|
||||||
|
$validated['priority'] ?? 'routine',
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Investigation request(s) submitted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, InvestigationRequest $investigation): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.view');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$investigation->load([
|
||||||
|
'patient', 'investigationType', 'practitioner', 'branch',
|
||||||
|
'result.values', 'result.attachments', 'assignedMember',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'lab.manage');
|
||||||
|
$canViewResults = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'lab.results.view');
|
||||||
|
|
||||||
|
return view('care.lab.requests.show', [
|
||||||
|
'investigation' => $investigation,
|
||||||
|
'statuses' => config('care.investigation_statuses'),
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canViewResults' => $canViewResults,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectSample(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$validated = $request->validate(['sample_barcode' => ['nullable', 'string', 'max:100']]);
|
||||||
|
|
||||||
|
$this->investigations->collectSample(
|
||||||
|
$investigation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['sample_barcode'] ?? null,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Sample collected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startProcessing(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$validated = $request->validate(['assigned_member_id' => ['nullable', 'integer', 'exists:care_members,id']]);
|
||||||
|
|
||||||
|
$this->investigations->startProcessing(
|
||||||
|
$investigation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated['assigned_member_id'] ?? null,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.lab.requests.show', $investigation)
|
||||||
|
->with('success', 'Processing started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enterResults(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'value' => ['nullable', 'string', 'max:255'],
|
||||||
|
'result_summary' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'interpretation' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'values' => ['nullable', 'array'],
|
||||||
|
'values.*.parameter' => ['nullable', 'string', 'max:255'],
|
||||||
|
'values.*.value' => ['nullable', 'string', 'max:255'],
|
||||||
|
'attachments' => ['nullable', 'array'],
|
||||||
|
'attachments.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->investigations->enterResults(
|
||||||
|
$investigation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Results submitted for review.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Results approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliver(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$this->investigations->deliver($investigation, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Results delivered to patient record.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, InvestigationRequest $investigation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeInvestigation($request, $investigation);
|
||||||
|
|
||||||
|
$this->investigations->cancel($investigation, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Investigation cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeInvestigation(Request $request, InvestigationRequest $investigation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $investigation);
|
||||||
|
abort_unless($investigation->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $investigation->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\InvestigationType;
|
||||||
|
use App\Services\Care\InvestigationService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class InvestigationTypeController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected InvestigationService $investigations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$types = InvestigationType::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->orderBy('category')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(30);
|
||||||
|
|
||||||
|
return view('care.lab.catalog.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'types' => $types,
|
||||||
|
'categories' => config('care.investigation_categories'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
|
||||||
|
return view('care.lab.catalog.create', [
|
||||||
|
'categories' => config('care.investigation_categories'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
|
||||||
|
$type = $this->investigations->createType(
|
||||||
|
$this->organization($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedTypeData($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.lab.catalog.index')
|
||||||
|
->with('success', "Investigation type \"{$type->name}\" created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, InvestigationType $investigationType): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeType($request, $investigationType);
|
||||||
|
|
||||||
|
return view('care.lab.catalog.edit', [
|
||||||
|
'type' => $investigationType,
|
||||||
|
'categories' => config('care.investigation_categories'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, InvestigationType $investigationType): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'lab.manage');
|
||||||
|
$this->authorizeType($request, $investigationType);
|
||||||
|
|
||||||
|
$this->investigations->updateType($investigationType, $this->ownerRef($request), $this->validatedTypeData($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.lab.catalog.index')
|
||||||
|
->with('success', 'Investigation type updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeType(Request $request, InvestigationType $type): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $type);
|
||||||
|
abort_unless($type->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedTypeData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'code' => ['nullable', 'string', 'max:50'],
|
||||||
|
'category' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.investigation_categories')))],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'unit' => ['nullable', 'string', 'max:50'],
|
||||||
|
'reference_low' => ['nullable', 'numeric'],
|
||||||
|
'reference_high' => ['nullable', 'numeric'],
|
||||||
|
'reference_text' => ['nullable', 'string', 'max:255'],
|
||||||
|
'price_minor' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Services\Care\AuditLogger;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.members.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$members = Member::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->with('branch')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.admin.members.index', [
|
||||||
|
'members' => $members,
|
||||||
|
'organization' => $organization,
|
||||||
|
'roles' => config('care.roles'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.members.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.admin.members.create', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'roles' => config('care.roles'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.members.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'user_ref' => ['required', 'string', 'max:255'],
|
||||||
|
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))],
|
||||||
|
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$member = Member::updateOrCreate(
|
||||||
|
[
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'user_ref' => $validated['user_ref'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'owner_ref' => $owner,
|
||||||
|
'role' => $validated['role'],
|
||||||
|
'branch_id' => $validated['branch_id'] ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id);
|
||||||
|
|
||||||
|
return redirect()->route('care.members.index')->with('success', 'Member saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Member $member): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'admin.members.manage');
|
||||||
|
$this->authorizeOwner($request, $member);
|
||||||
|
|
||||||
|
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
|
||||||
|
|
||||||
|
$memberId = $member->id;
|
||||||
|
$organizationId = $member->organization_id;
|
||||||
|
$member->delete();
|
||||||
|
|
||||||
|
AuditLogger::record($this->ownerRef($request), 'member.deleted', $organizationId, $this->ownerRef($request), Member::class, $memberId);
|
||||||
|
|
||||||
|
return redirect()->route('care.members.index')->with('success', 'Member removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Support\OrganizationBranding;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class OnboardingController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected OrganizationResolver $organizations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
if ($this->organizations->isOnboarded($request->user())) {
|
||||||
|
return redirect()->route('care.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('care.onboarding.show', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
'timezones' => timezone_identifiers_list(),
|
||||||
|
'facilityTypes' => [
|
||||||
|
'clinic' => 'Clinic',
|
||||||
|
'hospital' => 'Hospital',
|
||||||
|
'diagnostic' => 'Diagnostic laboratory',
|
||||||
|
'specialist' => 'Specialist practice',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($this->organizations->isOnboarded($request->user())) {
|
||||||
|
return redirect()->route('care.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'organization_name' => ['required', 'string', 'max:255'],
|
||||||
|
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
|
||||||
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
|
'branch_address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'branch_phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
'timezone' => ['required', 'timezone'],
|
||||||
|
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$organization = $this->organizations->completeOnboarding($request->user(), $validated);
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
$organization->update([
|
||||||
|
'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('care.dashboard')->with('success', 'Welcome to Ladill Care!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PatientService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PatientController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PatientService $patients,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$patients = $this->patients->search(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.patients.index', compact('patients', 'organization'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.patients.create', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'genders' => config('care.genders'),
|
||||||
|
'allergySeverities' => config('care.allergy_severities'),
|
||||||
|
'documentTypes' => config('care.document_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$validated = $this->validatedPatientData($request);
|
||||||
|
|
||||||
|
$patient = $this->patients->create(
|
||||||
|
$organization,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$validated,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.patients.show', $patient)
|
||||||
|
->with('success', 'Patient registered successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.view');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$dashboard = $this->patients->dashboard($patient);
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'patients.manage');
|
||||||
|
|
||||||
|
return view('care.patients.show', array_merge($dashboard, [
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'genders' => config('care.genders'),
|
||||||
|
'allergySeverities' => config('care.allergy_severities'),
|
||||||
|
'documentTypes' => config('care.document_types'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Patient $patient): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$patient->load(['allergies', 'conditions', 'familyHistory', 'emergencyContacts', 'insurancePolicies', 'attachments']);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.patients.edit', [
|
||||||
|
'patient' => $patient,
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'genders' => config('care.genders'),
|
||||||
|
'allergySeverities' => config('care.allergy_severities'),
|
||||||
|
'documentTypes' => config('care.document_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Patient $patient): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$validated = $this->validatedPatientData($request);
|
||||||
|
|
||||||
|
$this->patients->update($patient, $this->ownerRef($request), $validated, $this->ownerRef($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.patients.show', $patient)
|
||||||
|
->with('success', 'Patient record updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Patient $patient): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'patients.manage');
|
||||||
|
$this->authorizePatient($request, $patient);
|
||||||
|
|
||||||
|
$this->patients->delete($patient, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return redirect()->route('care.patients.index')
|
||||||
|
->with('success', 'Patient record archived.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePatient(Request $request, Patient $patient): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $patient);
|
||||||
|
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedPatientData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
||||||
|
'first_name' => ['required', 'string', 'max:100'],
|
||||||
|
'last_name' => ['required', 'string', 'max:100'],
|
||||||
|
'other_names' => ['nullable', 'string', 'max:100'],
|
||||||
|
'gender' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.genders')))],
|
||||||
|
'date_of_birth' => ['nullable', 'date', 'before:today'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'national_id' => ['nullable', 'string', 'max:50'],
|
||||||
|
'address' => ['nullable', 'string', 'max:500'],
|
||||||
|
'city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'region' => ['nullable', 'string', 'max:100'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'allergies' => ['nullable', 'array'],
|
||||||
|
'allergies.*.allergen' => ['nullable', 'string', 'max:255'],
|
||||||
|
'allergies.*.severity' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.allergy_severities')))],
|
||||||
|
'allergies.*.notes' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'conditions' => ['nullable', 'array'],
|
||||||
|
'conditions.*.condition' => ['nullable', 'string', 'max:255'],
|
||||||
|
'conditions.*.onset_date' => ['nullable', 'date'],
|
||||||
|
'conditions.*.is_chronic' => ['nullable', 'boolean'],
|
||||||
|
'conditions.*.notes' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'family_history' => ['nullable', 'array'],
|
||||||
|
'family_history.*.relation' => ['nullable', 'string', 'max:100'],
|
||||||
|
'family_history.*.condition' => ['nullable', 'string', 'max:255'],
|
||||||
|
'family_history.*.notes' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'emergency_contacts' => ['nullable', 'array'],
|
||||||
|
'emergency_contacts.*.name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'emergency_contacts.*.phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'emergency_contacts.*.relationship' => ['nullable', 'string', 'max:100'],
|
||||||
|
'emergency_contacts.*.is_primary' => ['nullable', 'boolean'],
|
||||||
|
'insurance' => ['nullable', 'array'],
|
||||||
|
'insurance.*.provider_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'insurance.*.policy_number' => ['nullable', 'string', 'max:100'],
|
||||||
|
'insurance.*.coverage_type' => ['nullable', 'string', 'max:100'],
|
||||||
|
'insurance.*.expiry_date' => ['nullable', 'date'],
|
||||||
|
'insurance.*.notes' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'attachments' => ['nullable', 'array'],
|
||||||
|
'attachments.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Models\Practitioner;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PharmacyService;
|
||||||
|
use App\Services\Care\PrescriptionService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PrescriptionController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PrescriptionService $prescriptions,
|
||||||
|
protected PharmacyService $pharmacy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$prescriptions = $this->prescriptions->list(
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$organization->id,
|
||||||
|
$request->only(['status', 'patient_id']),
|
||||||
|
$branchScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('care.prescriptions.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'prescriptions' => $prescriptions,
|
||||||
|
'statuses' => config('care.prescription_statuses'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queue(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$queue = $this->prescriptions->pharmacyQueue($this->ownerRef($request), $organization->id);
|
||||||
|
|
||||||
|
$drugs = \App\Models\Drug::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with(['batches' => fn ($q) => $q->where('quantity_on_hand', '>', 0)->orderBy('expiry_date')])
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$canDispense = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'prescriptions.dispense');
|
||||||
|
|
||||||
|
return view('care.prescriptions.queue', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'queue' => $queue,
|
||||||
|
'drugs' => $drugs,
|
||||||
|
'canDispense' => $canDispense,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request, Consultation $consultation): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$consultation->load(['patient', 'practitioner']);
|
||||||
|
|
||||||
|
$practitioners = Practitioner::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $this->organization($request)->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.prescriptions.create', [
|
||||||
|
'consultation' => $consultation,
|
||||||
|
'practitioners' => $practitioners,
|
||||||
|
'routes' => config('care.medication_routes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Consultation $consultation): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.manage');
|
||||||
|
$this->authorizeConsultation($request, $consultation);
|
||||||
|
|
||||||
|
$prescription = $this->prescriptions->createFromConsultation(
|
||||||
|
$consultation,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->validatedPrescriptionData($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.prescriptions.show', $prescription)
|
||||||
|
->with('success', 'Prescription created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Prescription $prescription): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.view');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
$prescription->load(['patient', 'practitioner', 'items', 'consultation', 'visit']);
|
||||||
|
|
||||||
|
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'prescriptions.manage');
|
||||||
|
$canDispense = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'prescriptions.dispense');
|
||||||
|
|
||||||
|
return view('care.prescriptions.show', [
|
||||||
|
'prescription' => $prescription,
|
||||||
|
'statuses' => config('care.prescription_statuses'),
|
||||||
|
'routes' => config('care.medication_routes'),
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canDispense' => $canDispense,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(Request $request, Prescription $prescription): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.manage');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
$this->prescriptions->activate($prescription, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Prescription activated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispense(Request $request, Prescription $prescription): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.dispense');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
$allocations = $request->input('allocations', []);
|
||||||
|
|
||||||
|
if (! empty($allocations)) {
|
||||||
|
$this->pharmacy->dispensePrescription(
|
||||||
|
$prescription,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$allocations,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->prescriptions->dispense($prescription, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('care.prescriptions.queue')
|
||||||
|
->with('success', 'Prescription dispensed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, Prescription $prescription): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'prescriptions.manage');
|
||||||
|
$this->authorizePrescription($request, $prescription);
|
||||||
|
|
||||||
|
$this->prescriptions->cancel($prescription, $this->ownerRef($request), $this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', 'Prescription cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $consultation);
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizePrescription(Request $request, Prescription $prescription): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $prescription);
|
||||||
|
abort_unless($prescription->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$prescription->loadMissing('visit');
|
||||||
|
abort_unless($prescription->visit->branch_id === $branchId, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validatedPrescriptionData(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
|
||||||
|
'notes' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'activate' => ['nullable', 'boolean'],
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.is_procedure' => ['nullable', 'boolean'],
|
||||||
|
'items.*.name' => ['required', 'string', 'max:255'],
|
||||||
|
'items.*.dosage' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.frequency' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.duration' => ['nullable', 'string', 'max:100'],
|
||||||
|
'items.*.route' => ['nullable', 'string', 'max:50'],
|
||||||
|
'items.*.quantity' => ['nullable', 'string', 'max:50'],
|
||||||
|
'items.*.instructions' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Practitioner;
|
||||||
|
use App\Services\Care\AppointmentService;
|
||||||
|
use App\Services\Care\ConsultationService;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class QueueController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
protected ConsultationService $consultations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'appointments.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->when($branchScope, fn ($q) => $q->where('id', $branchScope))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id);
|
||||||
|
$practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null;
|
||||||
|
|
||||||
|
$queue = $branchId
|
||||||
|
? $this->appointments->queue($this->ownerRef($request), $branchId, $practitionerId)
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$inConsultation = Appointment::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('status', Appointment::STATUS_IN_CONSULTATION)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
|
||||||
|
->with(['patient', 'practitioner', 'consultation'])
|
||||||
|
->orderBy('started_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$canManageQueue = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'queue.manage');
|
||||||
|
$canConsult = app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'consultations.manage');
|
||||||
|
|
||||||
|
return view('care.queue.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'branchId' => $branchId,
|
||||||
|
'practitioners' => Practitioner::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'practitionerId' => $practitionerId,
|
||||||
|
'queue' => $queue,
|
||||||
|
'inConsultation' => $inConsultation,
|
||||||
|
'canManageQueue' => $canManageQueue,
|
||||||
|
'canConsult' => $canConsult,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, Appointment $appointment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'consultations.manage');
|
||||||
|
$this->authorizeAppointment($request, $appointment);
|
||||||
|
|
||||||
|
$practitionerId = $request->input('practitioner_id')
|
||||||
|
? (int) $request->input('practitioner_id')
|
||||||
|
: $appointment->practitioner_id;
|
||||||
|
|
||||||
|
$this->appointments->startConsultation(
|
||||||
|
$appointment,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$practitionerId,
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
$consultation = $this->consultations->startFromAppointment(
|
||||||
|
$appointment->fresh(),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
$this->ownerRef($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('care.consultations.show', $consultation)
|
||||||
|
->with('success', 'Consultation started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeAppointment(Request $request, Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $appointment);
|
||||||
|
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
|
||||||
|
|
||||||
|
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
if ($branchId !== null && $appointment->branch_id !== $branchId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\ReportService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ReportService $reports,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'reports.finance.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.reports.index', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'branches' => $branches,
|
||||||
|
'reports' => config('care.report_types'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, string $type): View
|
||||||
|
{
|
||||||
|
$this->authorizeReport($request, $type);
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
||||||
|
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
$data = match ($type) {
|
||||||
|
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
||||||
|
default => abort(404),
|
||||||
|
};
|
||||||
|
|
||||||
|
$branches = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('care.reports.show', [
|
||||||
|
'type' => $type,
|
||||||
|
'label' => config('care.report_types')[$type] ?? $type,
|
||||||
|
'data' => $data,
|
||||||
|
'from' => $from->toDateString(),
|
||||||
|
'to' => $to->toDateString(),
|
||||||
|
'branchId' => $branchId,
|
||||||
|
'branches' => $branches,
|
||||||
|
'canExport' => app(\App\Services\Care\CarePermissions::class)
|
||||||
|
->can($this->member($request), 'reports.finance.export'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(Request $request, string $type): StreamedResponse
|
||||||
|
{
|
||||||
|
$this->authorizeReport($request, $type);
|
||||||
|
abort_unless(
|
||||||
|
app(\App\Services\Care\CarePermissions::class)->can($this->member($request), 'reports.finance.export'),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||||
|
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
||||||
|
[$from, $to] = $this->dateRange($request);
|
||||||
|
|
||||||
|
$data = match ($type) {
|
||||||
|
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||||
|
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
||||||
|
default => abort(404),
|
||||||
|
};
|
||||||
|
|
||||||
|
$filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($data, $type) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
if ($type === 'clinical' && isset($data['diagnoses'])) {
|
||||||
|
fputcsv($handle, ['Diagnosis', 'Count']);
|
||||||
|
foreach ($data['diagnoses'] as $row) {
|
||||||
|
fputcsv($handle, [$row->description, $row->total]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fputcsv($handle, ['Metric', 'Value']);
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
fputcsv($handle, [$key, is_scalar($value) ? $value : json_encode($value)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeReport(Request $request, string $type): void
|
||||||
|
{
|
||||||
|
abort_unless(array_key_exists($type, config('care.report_types')), 404);
|
||||||
|
$this->authorizeAbility($request, 'reports.finance.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Carbon, 1: Carbon}
|
||||||
|
*/
|
||||||
|
protected function dateRange(Request $request): array
|
||||||
|
{
|
||||||
|
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
|
||||||
|
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Services\Care\AuditLogger;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Support\OrganizationBranding;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function edit(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'settings.view');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
|
||||||
|
|
||||||
|
$branchCount = Branch::owned($this->ownerRef($request))
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return view('care.settings.edit', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'branchCount' => $branchCount,
|
||||||
|
'facilityTypes' => [
|
||||||
|
'clinic' => 'Clinic',
|
||||||
|
'hospital' => 'Hospital',
|
||||||
|
'diagnostic' => 'Diagnostic laboratory',
|
||||||
|
'specialist' => 'Specialist practice',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAbility($request, 'settings.manage');
|
||||||
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'timezone' => ['required', 'timezone'],
|
||||||
|
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
|
||||||
|
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||||
|
'remove_logo' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settings = $organization->settings ?? [];
|
||||||
|
$settings['onboarded'] = true;
|
||||||
|
$settings['facility_type'] = $validated['facility_type'];
|
||||||
|
|
||||||
|
$logoPath = $organization->logo_path;
|
||||||
|
|
||||||
|
if ($request->boolean('remove_logo')) {
|
||||||
|
OrganizationBranding::deleteStoredLogo($organization);
|
||||||
|
$logoPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
$logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'timezone' => $validated['timezone'],
|
||||||
|
'logo_path' => $logoPath,
|
||||||
|
'settings' => $settings,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
|
||||||
|
|
||||||
|
return back()->with('success', 'Settings saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic service-to-service auth for internal APIs. Validates the bearer token
|
||||||
|
* against per-consumer keys in config("{namespace}.service_api_keys") with a
|
||||||
|
* constant-time compare, and records the caller. Usage: auth.service:crm.
|
||||||
|
*/
|
||||||
|
class AuthenticateService
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
|
||||||
|
{
|
||||||
|
$token = (string) $request->bearerToken();
|
||||||
|
$caller = null;
|
||||||
|
|
||||||
|
if ($token !== '') {
|
||||||
|
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
|
||||||
|
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
|
||||||
|
$caller = $name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($caller === null) {
|
||||||
|
return response()->json(['error' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('service_caller', $caller);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureCareAbility
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected OrganizationResolver $organizations,
|
||||||
|
protected CarePermissions $permissions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next, string $ability): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$organization = $this->organizations->resolveForUser($user);
|
||||||
|
abort_unless($organization, 403);
|
||||||
|
|
||||||
|
$member = $this->organizations->memberFor($user, $organization);
|
||||||
|
abort_unless($this->permissions->can($member, $ability), 403);
|
||||||
|
|
||||||
|
$request->attributes->set('care.organization', $organization);
|
||||||
|
$request->attributes->set('care.member', $member);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureOrganizationSetup
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected OrganizationResolver $organizations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->routeIs('care.onboarding*')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->organizations->isOnboarded($user)) {
|
||||||
|
return redirect()->route('care.onboarding.show');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||||
|
* When the platform session ends, clear this app's local session too.
|
||||||
|
*/
|
||||||
|
class EnsurePlatformSession
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! Auth::check()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||||
|
if ($authDomain === '') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||||
|
if ($cookieHeader === '') {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||||
|
->timeout(3)
|
||||||
|
->get('https://'.$authDomain.'/sso/ping');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearAppSession(Request $request): Response
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $request->fullUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shares the signed-in account with views. CRM data is scoped per account by
|
||||||
|
* the user's public_id (owner_ref); today every user acts as their own
|
||||||
|
* account (team support graduates here later, like the platform pattern).
|
||||||
|
*/
|
||||||
|
class SetActingAccount
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($user = $request->user()) {
|
||||||
|
$request->attributes->set('actingAccount', $user);
|
||||||
|
|
||||||
|
if (! $request->is('api/*')) {
|
||||||
|
View::share('actingAccount', $user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reacts to platform lifecycle events pushed from the Ladill monolith.
|
||||||
|
*/
|
||||||
|
class PlatformServiceEventListener
|
||||||
|
{
|
||||||
|
public function handle(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
match ($event->name) {
|
||||||
|
'user.deleted' => $this->handleUserDeleted($event),
|
||||||
|
'user.suspended' => $this->handleUserSuspended($event),
|
||||||
|
'organization.updated' => $this->handleOrganizationUpdated($event),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleUserDeleted(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$publicId = (string) ($event->data['user'] ?? '');
|
||||||
|
if ($publicId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($publicId) {
|
||||||
|
Member::where('user_ref', $publicId)->delete();
|
||||||
|
User::where('public_id', $publicId)->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleUserSuspended(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$publicId = (string) ($event->data['user'] ?? '');
|
||||||
|
if ($publicId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('public_id', $publicId)->first();
|
||||||
|
if ($user) {
|
||||||
|
$user->tokens()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleOrganizationUpdated(ServiceEventOccurred $event): void
|
||||||
|
{
|
||||||
|
$ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? '');
|
||||||
|
if ($ownerRef === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = Organization::owned($ownerRef)->first();
|
||||||
|
if (! $organization) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = array_filter([
|
||||||
|
'name' => $event->data['name'] ?? null,
|
||||||
|
'timezone' => $event->data['timezone'] ?? null,
|
||||||
|
], fn ($value) => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
if ($updates === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization->update($updates);
|
||||||
|
|
||||||
|
Log::info('PlatformServiceEventListener: organization updated', [
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $subjectLine,
|
||||||
|
public string $bodyText,
|
||||||
|
public ?string $fromName = null,
|
||||||
|
public ?string $replyToAddress = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: $this->subjectLine,
|
||||||
|
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'email.contact-message',
|
||||||
|
with: [
|
||||||
|
'bodyText' => $this->bodyText,
|
||||||
|
'fromName' => $this->fromName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Appointment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_SCHEDULED = 'scheduled';
|
||||||
|
|
||||||
|
public const STATUS_CHECKED_IN = 'checked_in';
|
||||||
|
|
||||||
|
public const STATUS_WAITING = 'waiting';
|
||||||
|
|
||||||
|
public const STATUS_IN_CONSULTATION = 'in_consultation';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
public const STATUS_NO_SHOW = 'no_show';
|
||||||
|
|
||||||
|
public const TYPE_SCHEDULED = 'scheduled';
|
||||||
|
|
||||||
|
public const TYPE_WALK_IN = 'walk_in';
|
||||||
|
|
||||||
|
protected $table = 'care_appointments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
|
||||||
|
'practitioner_id', 'department_id', 'visit_id', 'type', 'status',
|
||||||
|
'scheduled_at', 'checked_in_at', 'waiting_at', 'started_at',
|
||||||
|
'completed_at', 'cancelled_at', 'queue_position', 'reason', 'notes', 'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scheduled_at' => 'datetime',
|
||||||
|
'checked_in_at' => 'datetime',
|
||||||
|
'waiting_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'cancelled_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Appointment $appointment) {
|
||||||
|
if (! $appointment->uuid) {
|
||||||
|
$appointment->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function department(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Department::class, 'department_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Consultation::class, 'appointment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function activeStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_SCHEDULED,
|
||||||
|
self::STATUS_CHECKED_IN,
|
||||||
|
self::STATUS_WAITING,
|
||||||
|
self::STATUS_IN_CONSULTATION,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_audit_logs';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'actor_ref', 'action',
|
||||||
|
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function record(
|
||||||
|
string $ownerRef,
|
||||||
|
string $action,
|
||||||
|
?int $organizationId = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $subjectType = null,
|
||||||
|
?int $subjectId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): self {
|
||||||
|
return static::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organizationId,
|
||||||
|
'actor_ref' => $actorRef,
|
||||||
|
'action' => $action,
|
||||||
|
'subject_type' => $subjectType,
|
||||||
|
'subject_id' => $subjectId,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'ip_address' => request()?->ip(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Bill extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_OPEN = 'open';
|
||||||
|
|
||||||
|
public const STATUS_PARTIAL = 'partial';
|
||||||
|
|
||||||
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
|
public const STATUS_VOID = 'void';
|
||||||
|
|
||||||
|
protected $table = 'care_bills';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
|
||||||
|
'invoice_number', 'status', 'subtotal_minor', 'discount_minor', 'tax_minor',
|
||||||
|
'total_minor', 'amount_paid_minor', 'balance_minor', 'notes', 'created_by', 'finalized_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['finalized_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Bill $bill) {
|
||||||
|
if (! $bill->uuid) {
|
||||||
|
$bill->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineItems(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(BillLineItem::class, 'bill_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Payment::class, 'bill_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class BillLineItem extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_bill_line_items';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'bill_id', 'type', 'description', 'quantity',
|
||||||
|
'unit_price_minor', 'total_minor', 'source_type', 'source_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function bill(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Bill::class, 'bill_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Branch extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_branches';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function departments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Department::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait BelongsToOwner
|
||||||
|
{
|
||||||
|
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||||
|
{
|
||||||
|
return $query->where($this->getTable().'.owner_ref', $ownerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Consultation extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
protected $table = 'care_consultations';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'visit_id', 'appointment_id', 'practitioner_id',
|
||||||
|
'patient_id', 'status', 'symptoms', 'clinical_notes',
|
||||||
|
'started_at', 'completed_at', 'completed_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Consultation $consultation) {
|
||||||
|
if (! $consultation->uuid) {
|
||||||
|
$consultation->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appointment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Appointment::class, 'appointment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vitalSigns(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VitalSign::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function diagnoses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Diagnosis::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function documents(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ConsultationDocument::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function investigationRequests(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InvestigationRequest::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prescriptions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Prescription::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ConsultationDocument extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_consultation_documents';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'consultation_id', 'file_path', 'original_name',
|
||||||
|
'mime_type', 'uploaded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url(): ?string
|
||||||
|
{
|
||||||
|
return $this->file_path && Storage::disk('public')->exists($this->file_path)
|
||||||
|
? Storage::disk('public')->url($this->file_path)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Department extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_departments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Diagnosis extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_diagnoses';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'consultation_id', 'code', 'description', 'is_primary', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_primary' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DispensingRecord extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_dispensing_records';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'prescription_id', 'prescription_item_id',
|
||||||
|
'drug_batch_id', 'quantity', 'dispensed_by', 'dispensed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['dispensed_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prescription(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Prescription::class, 'prescription_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prescriptionItem(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PrescriptionItem::class, 'prescription_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function drugBatch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DrugBatch::class, 'drug_batch_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Drug extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_drugs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'branch_id', 'name', 'generic_name',
|
||||||
|
'sku', 'unit', 'unit_price_minor', 'reorder_level', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function batches(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DrugBatch::class, 'drug_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stockOnHand(): int
|
||||||
|
{
|
||||||
|
return (int) $this->batches()->sum('quantity_on_hand');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class DrugBatch extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_drug_batches';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'drug_id', 'batch_number', 'expiry_date',
|
||||||
|
'quantity_on_hand', 'cost_minor', 'received_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expiry_date' => 'date',
|
||||||
|
'received_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function drug(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Drug::class, 'drug_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispensingRecords(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DispensingRecord::class, 'drug_batch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expiry_date !== null && $this->expiry_date->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class EmergencyContact extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_emergency_contacts';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'patient_id', 'name', 'phone', 'relationship', 'is_primary'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_primary' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class InsurancePolicy extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_insurance_policies';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'patient_id', 'provider_name', 'policy_number',
|
||||||
|
'coverage_type', 'expiry_date', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['expiry_date' => 'date'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class InvestigationAttachment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_investigation_attachments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'investigation_result_id', 'file_path', 'original_name',
|
||||||
|
'mime_type', 'uploaded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function result(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InvestigationResult::class, 'investigation_result_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class InvestigationRequest extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_SAMPLE_COLLECTED = 'sample_collected';
|
||||||
|
|
||||||
|
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
|
||||||
|
public const STATUS_AWAITING_REVIEW = 'awaiting_review';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_DELIVERED = 'delivered';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected $table = 'care_investigation_requests';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'consultation_id',
|
||||||
|
'patient_id', 'investigation_type_id', 'practitioner_id', 'status', 'priority',
|
||||||
|
'clinical_notes', 'requested_by', 'assigned_member_id', 'sample_barcode',
|
||||||
|
'sample_collected_at', 'sample_collected_by', 'completed_at', 'delivered_at',
|
||||||
|
'approved_by', 'approved_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sample_collected_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'delivered_at' => 'datetime',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (InvestigationRequest $request) {
|
||||||
|
if (! $request->uuid) {
|
||||||
|
$request->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function investigationType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InvestigationType::class, 'investigation_type_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignedMember(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class, 'assigned_member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function result(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(InvestigationResult::class, 'investigation_request_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function activeStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::STATUS_SAMPLE_COLLECTED,
|
||||||
|
self::STATUS_IN_PROGRESS,
|
||||||
|
self::STATUS_AWAITING_REVIEW,
|
||||||
|
self::STATUS_COMPLETED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class InvestigationResult extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_APPROVED = 'approved';
|
||||||
|
|
||||||
|
protected $table = 'care_investigation_results';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'investigation_request_id', 'result_summary', 'interpretation',
|
||||||
|
'is_abnormal', 'status', 'entered_by', 'approved_by', 'approved_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_abnormal' => 'boolean',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InvestigationRequest::class, 'investigation_request_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function values(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InvestigationResultValue::class, 'investigation_result_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InvestigationAttachment::class, 'investigation_result_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class InvestigationResultValue extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_investigation_result_values';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'investigation_result_id', 'parameter', 'value', 'unit',
|
||||||
|
'reference_low', 'reference_high', 'reference_text', 'is_abnormal',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reference_low' => 'decimal:4',
|
||||||
|
'reference_high' => 'decimal:4',
|
||||||
|
'is_abnormal' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function result(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InvestigationResult::class, 'investigation_result_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class InvestigationType extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_investigation_types';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'name', 'code', 'category', 'description',
|
||||||
|
'unit', 'reference_low', 'reference_high', 'reference_text', 'price_minor', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reference_low' => 'decimal:4',
|
||||||
|
'reference_high' => 'decimal:4',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requests(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InvestigationRequest::class, 'investigation_type_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Member extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_members';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRole(string ...$roles): bool
|
||||||
|
{
|
||||||
|
return in_array($this->role, $roles, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Organization extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_organizations';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['settings' => 'array'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branches(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Branch::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function members(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Member::class, 'organization_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Patient extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_patients';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_number',
|
||||||
|
'first_name', 'last_name', 'other_names', 'gender', 'date_of_birth',
|
||||||
|
'phone', 'email', 'national_id', 'address', 'city', 'region', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['date_of_birth' => 'date'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Patient $patient) {
|
||||||
|
if (! $patient->uuid) {
|
||||||
|
$patient->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fullName(): string
|
||||||
|
{
|
||||||
|
return trim(implode(' ', array_filter([
|
||||||
|
$this->first_name,
|
||||||
|
$this->other_names,
|
||||||
|
$this->last_name,
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allergies(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientAllergy::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function conditions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientCondition::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function familyHistory(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientFamilyHistory::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emergencyContacts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EmergencyContact::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insurancePolicies(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InsurancePolicy::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatientAttachment::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appointments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Appointment::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visits(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Visit::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function investigationRequests(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InvestigationRequest::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prescriptions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Prescription::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bills(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Bill::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PatientAllergy extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_patient_allergies';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'patient_id', 'allergen', 'severity', 'notes'];
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PatientAttachment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_patient_attachments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'patient_id', 'file_path', 'original_name',
|
||||||
|
'mime_type', 'document_type', 'uploaded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url(): ?string
|
||||||
|
{
|
||||||
|
return $this->file_path && Storage::disk('public')->exists($this->file_path)
|
||||||
|
? Storage::disk('public')->url($this->file_path)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PatientCondition extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_patient_conditions';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'patient_id', 'condition', 'onset_date', 'is_chronic', 'notes'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'onset_date' => 'date',
|
||||||
|
'is_chronic' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PatientFamilyHistory extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_patient_family_history';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'patient_id', 'relation', 'condition', 'notes'];
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Payment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_payments';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'bill_id', 'amount_minor', 'method',
|
||||||
|
'reference', 'paid_at', 'recorded_by', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['paid_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Payment $payment) {
|
||||||
|
if (! $payment->uuid) {
|
||||||
|
$payment->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bill(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Bill::class, 'bill_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Practitioner extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_practitioners';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'organization_id', 'branch_id', 'department_id', 'member_id',
|
||||||
|
'user_ref', 'name', 'specialty', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_active' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function department(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Department::class, 'department_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class, 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schedules(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PractitionerSchedule::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appointments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Appointment::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PractitionerSchedule extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_practitioner_schedules';
|
||||||
|
|
||||||
|
protected $fillable = ['owner_ref', 'practitioner_id', 'day_of_week', 'start_time', 'end_time'];
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Prescription extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_DISPENSED = 'dispensed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected $table = 'care_prescriptions';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'visit_id', 'consultation_id',
|
||||||
|
'patient_id', 'practitioner_id', 'status', 'notes',
|
||||||
|
'prescribed_by', 'dispensed_by', 'dispensed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['dispensed_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Prescription $prescription) {
|
||||||
|
if (! $prescription->uuid) {
|
||||||
|
$prescription->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Visit::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function practitioner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PrescriptionItem::class, 'prescription_id')->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PrescriptionItem extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_prescription_items';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'prescription_id', 'drug_id', 'is_procedure', 'name', 'dosage',
|
||||||
|
'frequency', 'duration', 'route', 'quantity', 'instructions', 'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['is_procedure' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prescription(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Prescription::class, 'prescription_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function drug(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Drug::class, 'drug_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||||
|
* Keyed by the OIDC `sub` (public_id); every Care record is scoped to it.
|
||||||
|
*/
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasApiTokens, Notifiable;
|
||||||
|
|
||||||
|
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
|
||||||
|
|
||||||
|
protected $hidden = ['password', 'remember_token'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'last_app_active_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ownerRef(): string
|
||||||
|
{
|
||||||
|
return (string) $this->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avatarUrl(): ?string
|
||||||
|
{
|
||||||
|
$url = trim((string) $this->avatar_url);
|
||||||
|
|
||||||
|
return $url !== '' ? $url : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Visit extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_OPEN = 'open';
|
||||||
|
|
||||||
|
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
protected $table = 'care_visits';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
|
||||||
|
'status', 'checked_in_at', 'completed_at', 'checked_in_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'checked_in_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Visit $visit) {
|
||||||
|
if (! $visit->uuid) {
|
||||||
|
$visit->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patient(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Patient::class, 'patient_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function branch(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Branch::class, 'branch_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appointment(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Appointment::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Consultation::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bill(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Bill::class, 'visit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bills(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Bill::class, 'visit_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOwner;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VitalSign extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner;
|
||||||
|
|
||||||
|
protected $table = 'care_vital_signs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref', 'consultation_id', 'bp_systolic', 'bp_diastolic', 'pulse',
|
||||||
|
'temperature', 'weight_kg', 'height_cm', 'spo2', 'respiratory_rate',
|
||||||
|
'recorded_by', 'recorded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'temperature' => 'decimal:1',
|
||||||
|
'weight_kg' => 'decimal:2',
|
||||||
|
'height_cm' => 'decimal:1',
|
||||||
|
'recorded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Events\ServiceEventOccurred;
|
||||||
|
use App\Listeners\PlatformServiceEventListener;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\OrganizationResolver;
|
||||||
|
use App\Services\Care\PlanService;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class);
|
||||||
|
|
||||||
|
View::composer('partials.sidebar', function ($view) {
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = auth()->user();
|
||||||
|
$isPro = false;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$organization = app(OrganizationResolver::class)->resolveForUser($user);
|
||||||
|
if ($organization) {
|
||||||
|
$isPro = app(PlanService::class)->isPro($organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$view->with('isPro', $isPro);
|
||||||
|
});
|
||||||
|
|
||||||
|
View::composer(['partials.topbar'], function ($view) {
|
||||||
|
$view->with(\App\Support\MobileTopbar::resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||||
|
* platform; CRM only consumes it. Amounts are integer minor units; the user is
|
||||||
|
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
|
||||||
|
*/
|
||||||
|
class BillingClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('billing.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) (config('billing.api_key') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanceMinor(string $publicId): int
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||||
|
->get($this->base().'/balance', ['user' => $publicId]);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (int) ($res->json('balance_minor') ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAfford(string $publicId, int $amountMinor): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||||
|
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (bool) ($res->json('affordable') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||||
|
* (HTTP 402). Idempotent by $reference.
|
||||||
|
*/
|
||||||
|
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => (string) config('billing.service', 'crm'),
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
|
||||||
|
if ($res->status() === 402) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\CrmPurchase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OneTimePurchaseService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BillingClient $billing)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
public function product(string $productKey): ?array
|
||||||
|
{
|
||||||
|
$product = config('crm_products.'.$productKey);
|
||||||
|
|
||||||
|
return is_array($product) ? $product : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPurchased(string $ownerRef, string $productKey): bool
|
||||||
|
{
|
||||||
|
return CrmPurchase::has($ownerRef, $productKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function priceMinor(string $productKey): int
|
||||||
|
{
|
||||||
|
return (int) ($this->product($productKey)['price_minor'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchase(string $ownerRef, string $productKey): bool
|
||||||
|
{
|
||||||
|
if ($this->hasPurchased($ownerRef, $productKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->product($productKey);
|
||||||
|
if (! $product) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$costMinor = (int) ($product['price_minor'] ?? 0);
|
||||||
|
|
||||||
|
if ($costMinor > 0) {
|
||||||
|
try {
|
||||||
|
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$charged = $this->billing->debit(
|
||||||
|
$ownerRef,
|
||||||
|
$costMinor,
|
||||||
|
'purchase',
|
||||||
|
'crm-purchase-'.$productKey.'-'.$ownerRef,
|
||||||
|
'CRM: '.($product['name'] ?? $productKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $charged) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CrmPurchase::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'product_key' => $productKey,
|
||||||
|
'cost_minor' => $costMinor,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class AppointmentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected VisitService $visits,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return Appointment::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->with(['patient', 'practitioner', 'branch', 'department'])
|
||||||
|
->orderByDesc('scheduled_at');
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $filters['status'] ?? null) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($practitionerId = $filters['practitioner_id'] ?? null) {
|
||||||
|
$query->where('practitioner_id', $practitionerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($date = $filters['date'] ?? null) {
|
||||||
|
$query->whereDate('scheduled_at', Carbon::parse($date)->toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientId = $filters['patient_id'] ?? null) {
|
||||||
|
$query->where('patient_id', $patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Appointment>
|
||||||
|
*/
|
||||||
|
public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection
|
||||||
|
{
|
||||||
|
$query = Appointment::owned($ownerRef)
|
||||||
|
->where('branch_id', $branchId)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->with(['patient', 'practitioner'])
|
||||||
|
->orderBy('queue_position')
|
||||||
|
->orderBy('waiting_at')
|
||||||
|
->orderBy('checked_in_at');
|
||||||
|
|
||||||
|
if ($practitionerId !== null) {
|
||||||
|
$query->where(function (Builder $q) use ($practitionerId) {
|
||||||
|
$q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function book(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$appointment = Appointment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $data['branch_id'],
|
||||||
|
'patient_id' => $data['patient_id'],
|
||||||
|
'practitioner_id' => $data['practitioner_id'] ?? null,
|
||||||
|
'department_id' => $data['department_id'] ?? null,
|
||||||
|
'type' => Appointment::TYPE_SCHEDULED,
|
||||||
|
'status' => Appointment::STATUS_SCHEDULED,
|
||||||
|
'scheduled_at' => $data['scheduled_at'],
|
||||||
|
'reason' => $data['reason'] ?? null,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
'created_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.created', $organization->id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->load(['patient', 'practitioner', 'branch']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function walkIn(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$visit = $this->visits->checkIn(
|
||||||
|
$organization,
|
||||||
|
$ownerRef,
|
||||||
|
Patient::findOrFail($data['patient_id']),
|
||||||
|
(int) $data['branch_id'],
|
||||||
|
$actorRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
$appointment = Appointment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $data['branch_id'],
|
||||||
|
'patient_id' => $data['patient_id'],
|
||||||
|
'practitioner_id' => $data['practitioner_id'] ?? null,
|
||||||
|
'department_id' => $data['department_id'] ?? null,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'type' => Appointment::TYPE_WALK_IN,
|
||||||
|
'status' => Appointment::STATUS_WAITING,
|
||||||
|
'scheduled_at' => now(),
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'queue_position' => $this->nextQueuePosition($ownerRef, (int) $data['branch_id']),
|
||||||
|
'reason' => $data['reason'] ?? 'Walk-in',
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
'created_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->load(['patient', 'practitioner', 'branch', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIn(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
|
||||||
|
|
||||||
|
$visit = $this->visits->checkIn(
|
||||||
|
$appointment->organization,
|
||||||
|
$ownerRef,
|
||||||
|
$appointment->patient,
|
||||||
|
$appointment->branch_id,
|
||||||
|
$actorRef,
|
||||||
|
);
|
||||||
|
|
||||||
|
$appointment->update([
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'status' => Appointment::STATUS_WAITING,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'waiting_at' => now(),
|
||||||
|
'queue_position' => $this->nextQueuePosition($ownerRef, $appointment->branch_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.checked_in', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markWaiting(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
if (! in_array($appointment->status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_SCHEDULED], true)) {
|
||||||
|
$this->assertTransition($appointment, Appointment::STATUS_CHECKED_IN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'status' => Appointment::STATUS_WAITING,
|
||||||
|
'waiting_at' => $appointment->waiting_at ?? now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($appointment->queue_position === null) {
|
||||||
|
$updates['queue_position'] = $this->nextQueuePosition($ownerRef, $appointment->branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment->update($updates);
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.waiting', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startConsultation(Appointment $appointment, string $ownerRef, ?int $practitionerId = null, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$this->assertTransition($appointment, Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN);
|
||||||
|
|
||||||
|
if ($appointment->visit_id === null) {
|
||||||
|
$visit = $this->visits->checkIn(
|
||||||
|
$appointment->organization,
|
||||||
|
$ownerRef,
|
||||||
|
$appointment->patient,
|
||||||
|
$appointment->branch_id,
|
||||||
|
$actorRef,
|
||||||
|
);
|
||||||
|
$appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]);
|
||||||
|
|
||||||
|
$appointment->update([
|
||||||
|
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||||
|
'started_at' => now(),
|
||||||
|
'practitioner_id' => $practitionerId ?? $appointment->practitioner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.in_consultation', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$this->assertTransition($appointment, Appointment::STATUS_IN_CONSULTATION);
|
||||||
|
|
||||||
|
$appointment->update([
|
||||||
|
'status' => Appointment::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($appointment->visit) {
|
||||||
|
$this->visits->complete($appointment->visit, $ownerRef, $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
if (in_array($appointment->status, [Appointment::STATUS_COMPLETED, Appointment::STATUS_CANCELLED], true)) {
|
||||||
|
throw new InvalidArgumentException('Appointment cannot be cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment->update([
|
||||||
|
'status' => Appointment::STATUS_CANCELLED,
|
||||||
|
'cancelled_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markNoShow(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
|
||||||
|
{
|
||||||
|
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
|
||||||
|
|
||||||
|
$appointment->update(['status' => Appointment::STATUS_NO_SHOW]);
|
||||||
|
AuditLogger::record($ownerRef, 'appointment.no_show', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
|
||||||
|
|
||||||
|
return $appointment->fresh(['patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callNext(string $ownerRef, int $branchId, ?int $practitionerId = null): ?Appointment
|
||||||
|
{
|
||||||
|
$next = $this->queue($ownerRef, $branchId, $practitionerId)->first();
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function nextQueuePosition(string $ownerRef, int $branchId): int
|
||||||
|
{
|
||||||
|
$max = Appointment::owned($ownerRef)
|
||||||
|
->where('branch_id', $branchId)
|
||||||
|
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
|
||||||
|
->max('queue_position');
|
||||||
|
|
||||||
|
return ($max ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void
|
||||||
|
{
|
||||||
|
if (! in_array($appointment->status, $allowedFrom, true)) {
|
||||||
|
throw new InvalidArgumentException("Cannot transition from {$appointment->status}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Member;
|
||||||
|
|
||||||
|
class AuditLogger
|
||||||
|
{
|
||||||
|
public static function record(
|
||||||
|
string $ownerRef,
|
||||||
|
string $action,
|
||||||
|
?int $organizationId = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $subjectType = null,
|
||||||
|
?int $subjectId = null,
|
||||||
|
?array $metadata = null,
|
||||||
|
): AuditLog {
|
||||||
|
return AuditLog::record(
|
||||||
|
$ownerRef,
|
||||||
|
$action,
|
||||||
|
$organizationId,
|
||||||
|
$actorRef,
|
||||||
|
$subjectType,
|
||||||
|
$subjectId,
|
||||||
|
$metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\BillLineItem;
|
||||||
|
use App\Models\InvestigationRequest;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class BillService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InvoiceNumberGenerator $invoices,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return Bill::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->with(['patient', 'branch', 'visit'])
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $filters['status'] ?? null) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientId = $filters['patient_id'] ?? null) {
|
||||||
|
$query->where('patient_id', $patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateFromVisit(Visit $visit, string $ownerRef, ?string $actorRef = null): Bill
|
||||||
|
{
|
||||||
|
$existing = Bill::owned($ownerRef)
|
||||||
|
->where('visit_id', $visit->id)
|
||||||
|
->whereNotIn('status', [Bill::STATUS_VOID])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->syncLineItemsFromVisit($existing, $ownerRef, $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit->load(['patient', 'consultations', 'organization']);
|
||||||
|
|
||||||
|
$bill = Bill::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $visit->organization_id,
|
||||||
|
'branch_id' => $visit->branch_id,
|
||||||
|
'visit_id' => $visit->id,
|
||||||
|
'patient_id' => $visit->patient_id,
|
||||||
|
'invoice_number' => $this->invoices->generate($visit->organization),
|
||||||
|
'status' => Bill::STATUS_OPEN,
|
||||||
|
'created_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncLineItemsFromVisit($bill, $ownerRef, $actorRef);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
|
||||||
|
|
||||||
|
return $bill->fresh(['lineItems', 'patient', 'payments']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
|
||||||
|
{
|
||||||
|
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
|
||||||
|
return $bill;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit = $bill->visit()->with(['consultations'])->firstOrFail();
|
||||||
|
|
||||||
|
$bill->lineItems()->whereNotNull('source_type')->delete();
|
||||||
|
|
||||||
|
if ($visit->consultations()->where('status', 'completed')->exists()) {
|
||||||
|
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
|
||||||
|
$this->addLineItem($bill, $ownerRef, [
|
||||||
|
'type' => 'consultation',
|
||||||
|
'description' => 'Consultation fee',
|
||||||
|
'quantity' => 1,
|
||||||
|
'unit_price_minor' => $fee,
|
||||||
|
'source_type' => Visit::class,
|
||||||
|
'source_id' => $visit->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
InvestigationRequest::owned($ownerRef)
|
||||||
|
->where('visit_id', $visit->id)
|
||||||
|
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
|
||||||
|
->with('investigationType')
|
||||||
|
->get()
|
||||||
|
->each(function (InvestigationRequest $request) use ($bill, $ownerRef) {
|
||||||
|
$this->addLineItem($bill, $ownerRef, [
|
||||||
|
'type' => 'lab',
|
||||||
|
'description' => $request->investigationType->name,
|
||||||
|
'quantity' => 1,
|
||||||
|
'unit_price_minor' => $request->investigationType->price_minor,
|
||||||
|
'source_type' => InvestigationRequest::class,
|
||||||
|
'source_id' => $request->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Prescription::owned($ownerRef)
|
||||||
|
->where('visit_id', $visit->id)
|
||||||
|
->where('status', Prescription::STATUS_DISPENSED)
|
||||||
|
->with('items.drug')
|
||||||
|
->get()
|
||||||
|
->each(function (Prescription $rx) use ($bill, $ownerRef) {
|
||||||
|
foreach ($rx->items as $item) {
|
||||||
|
if ($item->is_procedure) {
|
||||||
|
$this->addLineItem($bill, $ownerRef, [
|
||||||
|
'type' => 'procedure',
|
||||||
|
'description' => $item->name,
|
||||||
|
'quantity' => 1,
|
||||||
|
'unit_price_minor' => 0,
|
||||||
|
'source_type' => Prescription::class,
|
||||||
|
'source_id' => $rx->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = $item->drug?->unit_price_minor ?? 0;
|
||||||
|
$qty = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1);
|
||||||
|
$this->addLineItem($bill, $ownerRef, [
|
||||||
|
'type' => 'pharmacy',
|
||||||
|
'description' => $item->name,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'unit_price_minor' => $price,
|
||||||
|
'source_type' => Prescription::class,
|
||||||
|
'source_id' => $rx->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->recalculate($bill);
|
||||||
|
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
|
||||||
|
|
||||||
|
return $bill->fresh(['lineItems', 'payments', 'patient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function addLineItem(Bill $bill, string $ownerRef, array $data): BillLineItem
|
||||||
|
{
|
||||||
|
$this->assertEditable($bill);
|
||||||
|
|
||||||
|
$quantity = max(1, (int) ($data['quantity'] ?? 1));
|
||||||
|
$unitPrice = (int) ($data['unit_price_minor'] ?? 0);
|
||||||
|
|
||||||
|
return BillLineItem::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'bill_id' => $bill->id,
|
||||||
|
'type' => $data['type'] ?? 'misc',
|
||||||
|
'description' => $data['description'],
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price_minor' => $unitPrice,
|
||||||
|
'total_minor' => $quantity * $unitPrice,
|
||||||
|
'source_type' => $data['source_type'] ?? null,
|
||||||
|
'source_id' => $data['source_id'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function addManualLineItem(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Bill
|
||||||
|
{
|
||||||
|
$this->addLineItem($bill, $ownerRef, $data);
|
||||||
|
$this->recalculate($bill);
|
||||||
|
AuditLogger::record($ownerRef, 'bill.line_item_added', $bill->organization_id, $actorRef, Bill::class, $bill->id);
|
||||||
|
|
||||||
|
return $bill->fresh(['lineItems', 'payments']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyAdjustments(Bill $bill, string $ownerRef, int $discountMinor = 0, int $taxMinor = 0, ?string $actorRef = null): Bill
|
||||||
|
{
|
||||||
|
$this->assertEditable($bill);
|
||||||
|
|
||||||
|
$bill->update([
|
||||||
|
'discount_minor' => max(0, $discountMinor),
|
||||||
|
'tax_minor' => max(0, $taxMinor),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recalculate($bill);
|
||||||
|
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
|
||||||
|
|
||||||
|
return $bill->fresh(['lineItems', 'payments']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function recordPayment(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Payment
|
||||||
|
{
|
||||||
|
if ($bill->status === Bill::STATUS_VOID) {
|
||||||
|
throw new InvalidArgumentException('Cannot pay a void bill.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = (int) $data['amount_minor'];
|
||||||
|
if ($amount <= 0) {
|
||||||
|
throw new InvalidArgumentException('Payment amount must be positive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment = Payment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'bill_id' => $bill->id,
|
||||||
|
'amount_minor' => $amount,
|
||||||
|
'method' => $data['method'] ?? 'cash',
|
||||||
|
'reference' => $data['reference'] ?? null,
|
||||||
|
'paid_at' => $data['paid_at'] ?? now(),
|
||||||
|
'recorded_by' => $actorRef,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bill->refresh();
|
||||||
|
$paid = (int) $bill->payments()->sum('amount_minor');
|
||||||
|
$balance = max(0, $bill->total_minor - $paid);
|
||||||
|
|
||||||
|
$status = Bill::STATUS_OPEN;
|
||||||
|
if ($paid > 0 && $balance > 0) {
|
||||||
|
$status = Bill::STATUS_PARTIAL;
|
||||||
|
} elseif ($balance === 0 && $bill->total_minor > 0) {
|
||||||
|
$status = Bill::STATUS_PAID;
|
||||||
|
} elseif ($paid >= $bill->total_minor) {
|
||||||
|
$status = Bill::STATUS_PAID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bill->update([
|
||||||
|
'amount_paid_minor' => $paid,
|
||||||
|
'balance_minor' => $balance,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
|
||||||
|
'bill_id' => $bill->id,
|
||||||
|
'amount_minor' => $amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
|
||||||
|
{
|
||||||
|
if ($bill->status === Bill::STATUS_PAID) {
|
||||||
|
throw new InvalidArgumentException('Paid bills cannot be voided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bill->update(['status' => Bill::STATUS_VOID, 'balance_minor' => 0]);
|
||||||
|
AuditLogger::record($ownerRef, 'bill.voided', $bill->organization_id, $actorRef, Bill::class, $bill->id);
|
||||||
|
|
||||||
|
return $bill->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function recalculate(Bill $bill): void
|
||||||
|
{
|
||||||
|
$bill->refresh();
|
||||||
|
$subtotal = (int) $bill->lineItems()->sum('total_minor');
|
||||||
|
$total = max(0, $subtotal - $bill->discount_minor + $bill->tax_minor);
|
||||||
|
$paid = (int) $bill->payments()->sum('amount_minor');
|
||||||
|
$balance = max(0, $total - $paid);
|
||||||
|
|
||||||
|
$status = $bill->status;
|
||||||
|
if ($status !== Bill::STATUS_VOID) {
|
||||||
|
if ($paid === 0) {
|
||||||
|
$status = Bill::STATUS_OPEN;
|
||||||
|
} elseif ($balance > 0) {
|
||||||
|
$status = Bill::STATUS_PARTIAL;
|
||||||
|
} else {
|
||||||
|
$status = Bill::STATUS_PAID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bill->update([
|
||||||
|
'subtotal_minor' => $subtotal,
|
||||||
|
'total_minor' => $total,
|
||||||
|
'amount_paid_minor' => $paid,
|
||||||
|
'balance_minor' => $balance,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEditable(Bill $bill): void
|
||||||
|
{
|
||||||
|
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
|
||||||
|
throw new InvalidArgumentException('Bill cannot be modified.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
|
||||||
|
class CarePermissions
|
||||||
|
{
|
||||||
|
/** @var array<string, list<string>> */
|
||||||
|
protected array $roleAbilities = [
|
||||||
|
'super_admin' => ['*'],
|
||||||
|
'hospital_admin' => ['*'],
|
||||||
|
'receptionist' => [
|
||||||
|
'dashboard.view', 'patients.view', 'patients.manage',
|
||||||
|
'appointments.view', 'appointments.manage', 'queue.manage',
|
||||||
|
],
|
||||||
|
'doctor' => [
|
||||||
|
'dashboard.view', 'patients.view', 'appointments.view',
|
||||||
|
'consultations.view', 'consultations.manage',
|
||||||
|
'investigations.request', 'prescriptions.manage', 'lab.results.view',
|
||||||
|
],
|
||||||
|
'nurse' => [
|
||||||
|
'dashboard.view', 'patients.view', 'appointments.view',
|
||||||
|
'consultations.view', 'vitals.manage', 'queue.manage',
|
||||||
|
],
|
||||||
|
'lab_technician' => [
|
||||||
|
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
|
||||||
|
],
|
||||||
|
'pharmacist' => [
|
||||||
|
'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense',
|
||||||
|
'pharmacy.view', 'pharmacy.manage',
|
||||||
|
],
|
||||||
|
'cashier' => [
|
||||||
|
'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage',
|
||||||
|
],
|
||||||
|
'accountant' => [
|
||||||
|
'dashboard.view', 'bills.view', 'reports.finance.view', 'reports.finance.export',
|
||||||
|
'audit.view', 'audit.export',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
protected array $adminAbilities = [
|
||||||
|
'admin.branches.view', 'admin.branches.manage',
|
||||||
|
'admin.departments.view', 'admin.departments.manage',
|
||||||
|
'admin.members.view', 'admin.members.manage',
|
||||||
|
'settings.view', 'settings.manage',
|
||||||
|
'audit.view', 'audit.export',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function can(?Member $member, string $ability): bool
|
||||||
|
{
|
||||||
|
if ($member === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$abilities = $this->roleAbilities[$member->role] ?? [];
|
||||||
|
|
||||||
|
if (in_array('*', $abilities, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($ability, $abilities, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(?Member $member): bool
|
||||||
|
{
|
||||||
|
return $member !== null && in_array($member->role, ['super_admin', 'hospital_admin'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\ConsultationDocument;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\VitalSign;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class ConsultationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AppointmentService $appointments,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function startFromAppointment(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Consultation
|
||||||
|
{
|
||||||
|
if ($appointment->status !== Appointment::STATUS_IN_CONSULTATION) {
|
||||||
|
$this->appointments->startConsultation(
|
||||||
|
$appointment,
|
||||||
|
$ownerRef,
|
||||||
|
$appointment->practitioner_id,
|
||||||
|
$actorRef,
|
||||||
|
);
|
||||||
|
$appointment->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = Consultation::where('appointment_id', $appointment->id)->first();
|
||||||
|
if ($existing) {
|
||||||
|
return $existing->load(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$consultation = Consultation::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'visit_id' => $appointment->visit_id,
|
||||||
|
'appointment_id' => $appointment->id,
|
||||||
|
'practitioner_id' => $appointment->practitioner_id,
|
||||||
|
'patient_id' => $appointment->patient_id,
|
||||||
|
'status' => Consultation::STATUS_DRAFT,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'consultation.started', $appointment->organization_id, $actorRef, Consultation::class, $consultation->id);
|
||||||
|
|
||||||
|
return $consultation->load(['patient', 'practitioner', 'visit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function save(Consultation $consultation, string $ownerRef, array $data, ?string $actorRef = null): Consultation
|
||||||
|
{
|
||||||
|
$consultation->update([
|
||||||
|
'symptoms' => $data['symptoms'] ?? $consultation->symptoms,
|
||||||
|
'clinical_notes' => $data['clinical_notes'] ?? $consultation->clinical_notes,
|
||||||
|
'practitioner_id' => $data['practitioner_id'] ?? $consultation->practitioner_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (array_key_exists('vitals', $data) && is_array($data['vitals'])) {
|
||||||
|
$this->saveVitals($consultation, $ownerRef, $data['vitals'], $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('diagnoses', $data)) {
|
||||||
|
$this->syncDiagnoses($consultation, $ownerRef, $data['diagnoses']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($data['documents'])) {
|
||||||
|
$this->storeDocuments($consultation, $ownerRef, $data['documents'], $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'consultation.updated', $consultation->visit->organization_id, $actorRef, Consultation::class, $consultation->id);
|
||||||
|
|
||||||
|
return $consultation->fresh(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner', 'visit', 'appointment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Consultation $consultation, string $ownerRef, ?string $actorRef = null): Consultation
|
||||||
|
{
|
||||||
|
$consultation->update([
|
||||||
|
'status' => Consultation::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'completed_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($consultation->appointment) {
|
||||||
|
$this->appointments->complete($consultation->appointment, $ownerRef, $actorRef);
|
||||||
|
} elseif ($consultation->visit) {
|
||||||
|
app(VisitService::class)->complete($consultation->visit, $ownerRef, $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'consultation.completed', $consultation->visit->organization_id, $actorRef, Consultation::class, $consultation->id);
|
||||||
|
|
||||||
|
return $consultation->fresh(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $vitals
|
||||||
|
*/
|
||||||
|
protected function saveVitals(Consultation $consultation, string $ownerRef, array $vitals, ?string $actorRef): void
|
||||||
|
{
|
||||||
|
if (empty(array_filter($vitals))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
VitalSign::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'bp_systolic' => $vitals['bp_systolic'] ?? null,
|
||||||
|
'bp_diastolic' => $vitals['bp_diastolic'] ?? null,
|
||||||
|
'pulse' => $vitals['pulse'] ?? null,
|
||||||
|
'temperature' => $vitals['temperature'] ?? null,
|
||||||
|
'weight_kg' => $vitals['weight_kg'] ?? null,
|
||||||
|
'height_cm' => $vitals['height_cm'] ?? null,
|
||||||
|
'spo2' => $vitals['spo2'] ?? null,
|
||||||
|
'respiratory_rate' => $vitals['respiratory_rate'] ?? null,
|
||||||
|
'recorded_by' => $actorRef,
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
protected function syncDiagnoses(Consultation $consultation, string $ownerRef, array $rows): void
|
||||||
|
{
|
||||||
|
$consultation->diagnoses()->delete();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (empty($row['description'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Diagnosis::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'code' => $row['code'] ?? null,
|
||||||
|
'description' => $row['description'],
|
||||||
|
'is_primary' => (bool) ($row['is_primary'] ?? false),
|
||||||
|
'notes' => $row['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, UploadedFile> $files
|
||||||
|
*/
|
||||||
|
protected function storeDocuments(Consultation $consultation, string $ownerRef, array $files, ?string $actorRef): void
|
||||||
|
{
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $file->store("care/consultations/{$consultation->id}/documents", 'public');
|
||||||
|
|
||||||
|
ConsultationDocument::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'file_path' => $path,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
'uploaded_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\InvestigationAttachment;
|
||||||
|
use App\Models\InvestigationRequest;
|
||||||
|
use App\Models\InvestigationResult;
|
||||||
|
use App\Models\InvestigationResultValue;
|
||||||
|
use App\Models\InvestigationType;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class InvestigationService
|
||||||
|
{
|
||||||
|
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return InvestigationRequest::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->with(['patient', 'investigationType', 'practitioner', 'branch', 'result'])
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $filters['status'] ?? null) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientId = $filters['patient_id'] ?? null) {
|
||||||
|
$query->where('patient_id', $patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, InvestigationRequest>
|
||||||
|
*/
|
||||||
|
public function workQueue(string $ownerRef, int $branchId, ?string $status = null): Collection
|
||||||
|
{
|
||||||
|
$query = InvestigationRequest::owned($ownerRef)
|
||||||
|
->where('branch_id', $branchId)
|
||||||
|
->whereIn('status', InvestigationRequest::activeStatuses())
|
||||||
|
->with(['patient', 'investigationType', 'assignedMember', 'result'])
|
||||||
|
->orderByRaw("CASE priority WHEN 'urgent' THEN 0 ELSE 1 END")
|
||||||
|
->orderBy('created_at');
|
||||||
|
|
||||||
|
if ($status) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $typeIds
|
||||||
|
* @return Collection<int, InvestigationRequest>
|
||||||
|
*/
|
||||||
|
public function requestFromConsultation(
|
||||||
|
Consultation $consultation,
|
||||||
|
string $ownerRef,
|
||||||
|
array $typeIds,
|
||||||
|
?string $clinicalNotes = null,
|
||||||
|
string $priority = 'routine',
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Collection {
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
$created = new Collection();
|
||||||
|
|
||||||
|
foreach ($typeIds as $typeId) {
|
||||||
|
$type = InvestigationType::owned($ownerRef)->findOrFail($typeId);
|
||||||
|
|
||||||
|
$request = InvestigationRequest::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $consultation->visit->organization_id,
|
||||||
|
'branch_id' => $consultation->visit->branch_id,
|
||||||
|
'visit_id' => $consultation->visit_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'patient_id' => $consultation->patient_id,
|
||||||
|
'investigation_type_id' => $type->id,
|
||||||
|
'practitioner_id' => $consultation->practitioner_id,
|
||||||
|
'status' => InvestigationRequest::STATUS_PENDING,
|
||||||
|
'priority' => $priority,
|
||||||
|
'clinical_notes' => $clinicalNotes,
|
||||||
|
'requested_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'investigation.requested',
|
||||||
|
$consultation->visit->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
InvestigationRequest::class,
|
||||||
|
$request->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$created->push($request->load('investigationType'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectSample(
|
||||||
|
InvestigationRequest $request,
|
||||||
|
string $ownerRef,
|
||||||
|
?string $barcode = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): InvestigationRequest {
|
||||||
|
$this->assertStatus($request, InvestigationRequest::STATUS_PENDING);
|
||||||
|
|
||||||
|
$request->update([
|
||||||
|
'status' => InvestigationRequest::STATUS_SAMPLE_COLLECTED,
|
||||||
|
'sample_barcode' => $barcode ?? $this->generateBarcode($request),
|
||||||
|
'sample_collected_at' => now(),
|
||||||
|
'sample_collected_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.sample_collected', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
|
||||||
|
|
||||||
|
return $request->fresh(['patient', 'investigationType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startProcessing(
|
||||||
|
InvestigationRequest $request,
|
||||||
|
string $ownerRef,
|
||||||
|
?int $assignedMemberId = null,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): InvestigationRequest {
|
||||||
|
$this->assertStatus($request, InvestigationRequest::STATUS_SAMPLE_COLLECTED);
|
||||||
|
|
||||||
|
$request->update([
|
||||||
|
'status' => InvestigationRequest::STATUS_IN_PROGRESS,
|
||||||
|
'assigned_member_id' => $assignedMemberId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
InvestigationResult::firstOrCreate(
|
||||||
|
['investigation_request_id' => $request->id],
|
||||||
|
['owner_ref' => $ownerRef, 'entered_by' => $actorRef, 'status' => InvestigationResult::STATUS_DRAFT],
|
||||||
|
);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.in_progress', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
|
||||||
|
|
||||||
|
return $request->fresh(['patient', 'investigationType', 'result']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function enterResults(
|
||||||
|
InvestigationRequest $request,
|
||||||
|
string $ownerRef,
|
||||||
|
array $data,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): InvestigationResult {
|
||||||
|
if (! in_array($request->status, [InvestigationRequest::STATUS_IN_PROGRESS, InvestigationRequest::STATUS_AWAITING_REVIEW], true)) {
|
||||||
|
throw new InvalidArgumentException('Results can only be entered when processing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = InvestigationResult::firstOrCreate(
|
||||||
|
['investigation_request_id' => $request->id],
|
||||||
|
['owner_ref' => $ownerRef, 'entered_by' => $actorRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
$type = $request->investigationType;
|
||||||
|
$isAbnormal = false;
|
||||||
|
|
||||||
|
if (! empty($data['values']) && is_array($data['values'])) {
|
||||||
|
$result->values()->delete();
|
||||||
|
foreach ($data['values'] as $row) {
|
||||||
|
if (empty($row['parameter'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$abnormal = $this->isValueAbnormal(
|
||||||
|
$row['value'] ?? null,
|
||||||
|
$row['reference_low'] ?? $type->reference_low,
|
||||||
|
$row['reference_high'] ?? $type->reference_high,
|
||||||
|
);
|
||||||
|
$isAbnormal = $isAbnormal || $abnormal;
|
||||||
|
|
||||||
|
InvestigationResultValue::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'investigation_result_id' => $result->id,
|
||||||
|
'parameter' => $row['parameter'],
|
||||||
|
'value' => $row['value'] ?? null,
|
||||||
|
'unit' => $row['unit'] ?? $type->unit,
|
||||||
|
'reference_low' => $row['reference_low'] ?? $type->reference_low,
|
||||||
|
'reference_high' => $row['reference_high'] ?? $type->reference_high,
|
||||||
|
'reference_text' => $row['reference_text'] ?? $type->reference_text,
|
||||||
|
'is_abnormal' => $abnormal,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} elseif (! empty($data['value'])) {
|
||||||
|
$result->values()->delete();
|
||||||
|
$abnormal = $this->isValueAbnormal($data['value'], $type->reference_low, $type->reference_high);
|
||||||
|
$isAbnormal = $abnormal;
|
||||||
|
InvestigationResultValue::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'investigation_result_id' => $result->id,
|
||||||
|
'parameter' => $type->name,
|
||||||
|
'value' => $data['value'],
|
||||||
|
'unit' => $type->unit,
|
||||||
|
'reference_low' => $type->reference_low,
|
||||||
|
'reference_high' => $type->reference_high,
|
||||||
|
'reference_text' => $type->reference_text,
|
||||||
|
'is_abnormal' => $abnormal,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result->update([
|
||||||
|
'result_summary' => $data['result_summary'] ?? $result->result_summary,
|
||||||
|
'interpretation' => $data['interpretation'] ?? $result->interpretation,
|
||||||
|
'is_abnormal' => $isAbnormal,
|
||||||
|
'entered_by' => $actorRef ?? $result->entered_by,
|
||||||
|
'status' => InvestigationResult::STATUS_DRAFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($data['attachments'])) {
|
||||||
|
$this->storeAttachments($result, $ownerRef, $data['attachments'], $actorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->update(['status' => InvestigationRequest::STATUS_AWAITING_REVIEW]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.results_entered', $request->organization_id, $actorRef, InvestigationResult::class, $result->id);
|
||||||
|
|
||||||
|
return $result->fresh(['values', 'attachments']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
|
||||||
|
{
|
||||||
|
$this->assertStatus($request, InvestigationRequest::STATUS_AWAITING_REVIEW);
|
||||||
|
|
||||||
|
$result = $request->result;
|
||||||
|
abort_unless($result, 422, 'No results to approve.');
|
||||||
|
|
||||||
|
$result->update([
|
||||||
|
'status' => InvestigationResult::STATUS_APPROVED,
|
||||||
|
'approved_by' => $actorRef,
|
||||||
|
'approved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->update([
|
||||||
|
'status' => InvestigationRequest::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'approved_by' => $actorRef,
|
||||||
|
'approved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
|
||||||
|
|
||||||
|
return $request->fresh(['patient', 'investigationType', 'result.values']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliver(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
|
||||||
|
{
|
||||||
|
$this->assertStatus($request, InvestigationRequest::STATUS_COMPLETED);
|
||||||
|
|
||||||
|
$request->update([
|
||||||
|
'status' => InvestigationRequest::STATUS_DELIVERED,
|
||||||
|
'delivered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.delivered', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
|
||||||
|
|
||||||
|
return $request->fresh(['patient', 'investigationType', 'result']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
|
||||||
|
{
|
||||||
|
if (in_array($request->status, [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED], true)) {
|
||||||
|
throw new InvalidArgumentException('Cannot cancel a completed investigation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->update(['status' => InvestigationRequest::STATUS_CANCELLED]);
|
||||||
|
AuditLogger::record($ownerRef, 'investigation.cancelled', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
|
||||||
|
|
||||||
|
return $request->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function createType(Organization $organization, string $ownerRef, array $data): InvestigationType
|
||||||
|
{
|
||||||
|
$type = InvestigationType::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'name' => $data['name'],
|
||||||
|
'code' => $data['code'] ?? null,
|
||||||
|
'category' => $data['category'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'unit' => $data['unit'] ?? null,
|
||||||
|
'reference_low' => $data['reference_low'] ?? null,
|
||||||
|
'reference_high' => $data['reference_high'] ?? null,
|
||||||
|
'reference_text' => $data['reference_text'] ?? null,
|
||||||
|
'price_minor' => (int) ($data['price_minor'] ?? 0),
|
||||||
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation_type.created', $organization->id, null, InvestigationType::class, $type->id);
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function updateType(InvestigationType $type, string $ownerRef, array $data): InvestigationType
|
||||||
|
{
|
||||||
|
$type->update([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'code' => $data['code'] ?? null,
|
||||||
|
'category' => $data['category'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'unit' => $data['unit'] ?? null,
|
||||||
|
'reference_low' => $data['reference_low'] ?? null,
|
||||||
|
'reference_high' => $data['reference_high'] ?? null,
|
||||||
|
'reference_text' => $data['reference_text'] ?? null,
|
||||||
|
'price_minor' => (int) ($data['price_minor'] ?? 0),
|
||||||
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'investigation_type.updated', $type->organization_id, null, InvestigationType::class, $type->id);
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertStatus(InvestigationRequest $request, string ...$allowed): void
|
||||||
|
{
|
||||||
|
if (! in_array($request->status, $allowed, true)) {
|
||||||
|
throw new InvalidArgumentException("Cannot transition from {$request->status}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isValueAbnormal(?string $value, mixed $low, mixed $high): bool
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeric = (float) $value;
|
||||||
|
|
||||||
|
if ($low !== null && $numeric < (float) $low) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($high !== null && $numeric > (float) $high) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateBarcode(InvestigationRequest $request): string
|
||||||
|
{
|
||||||
|
return 'LAB-'.str_pad((string) $request->id, 6, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, UploadedFile> $files
|
||||||
|
*/
|
||||||
|
protected function storeAttachments(InvestigationResult $result, string $ownerRef, array $files, ?string $actorRef): void
|
||||||
|
{
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $file->store("care/investigations/{$result->id}/attachments", 'public');
|
||||||
|
|
||||||
|
InvestigationAttachment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'investigation_result_id' => $result->id,
|
||||||
|
'file_path' => $path,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
'uploaded_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class InvoiceNumberGenerator
|
||||||
|
{
|
||||||
|
public function generate(Organization $organization): string
|
||||||
|
{
|
||||||
|
$prefix = config('care.invoice_number.prefix', 'INV');
|
||||||
|
$year = now()->format('Y');
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($organization, $prefix, $year) {
|
||||||
|
$latest = Bill::withTrashed()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('invoice_number', 'like', "{$prefix}-{$year}-%")
|
||||||
|
->orderByDesc('invoice_number')
|
||||||
|
->lockForUpdate()
|
||||||
|
->value('invoice_number');
|
||||||
|
|
||||||
|
$sequence = 1;
|
||||||
|
if ($latest && preg_match('/-(\d+)$/', $latest, $matches)) {
|
||||||
|
$sequence = (int) $matches[1] + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s-%s-%05d', $prefix, $year, $sequence);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OrganizationResolver
|
||||||
|
{
|
||||||
|
public function resolveForUser(User $user): ?Organization
|
||||||
|
{
|
||||||
|
$ref = $user->ownerRef();
|
||||||
|
|
||||||
|
$member = Member::where('user_ref', $ref)->first();
|
||||||
|
if ($member) {
|
||||||
|
return Organization::find($member->organization_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Organization::owned($ref)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forUser(User $user): Organization
|
||||||
|
{
|
||||||
|
return $this->resolveForUser($user)
|
||||||
|
?? throw new \RuntimeException('No organization for user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOnboarded(User $user): bool
|
||||||
|
{
|
||||||
|
$organization = $this->resolveForUser($user);
|
||||||
|
|
||||||
|
return $organization !== null
|
||||||
|
&& (bool) data_get($organization->settings, 'onboarded', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function memberFor(User $user, ?Organization $organization = null): ?Member
|
||||||
|
{
|
||||||
|
$organization ??= $this->resolveForUser($user);
|
||||||
|
if (! $organization) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Member::where('organization_id', $organization->id)
|
||||||
|
->where('user_ref', $user->ownerRef())
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureOwnerMember(User $user, Organization $organization): Member
|
||||||
|
{
|
||||||
|
return Member::firstOrCreate(
|
||||||
|
[
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'user_ref' => $user->ownerRef(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'owner_ref' => $user->ownerRef(),
|
||||||
|
'role' => 'hospital_admin',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function completeOnboarding(User $user, array $data): Organization
|
||||||
|
{
|
||||||
|
$ref = $user->ownerRef();
|
||||||
|
|
||||||
|
$organization = Organization::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'name' => $data['organization_name'],
|
||||||
|
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
|
||||||
|
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'facility_type' => $data['facility_type'] ?? 'clinic',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->ensureOwnerMember($user, $organization);
|
||||||
|
|
||||||
|
$branch = Branch::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'name' => $data['branch_name'],
|
||||||
|
'address' => $data['branch_address'] ?? null,
|
||||||
|
'phone' => $data['branch_phone'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Department::create([
|
||||||
|
'owner_ref' => $ref,
|
||||||
|
'branch_id' => $branch->id,
|
||||||
|
'name' => 'General Outpatient',
|
||||||
|
'type' => 'outpatient',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
||||||
|
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
||||||
|
|
||||||
|
return $organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch ID the member may access; null = all branches. */
|
||||||
|
public function branchScope(?Member $member): ?int
|
||||||
|
{
|
||||||
|
if ($member === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($member->role, ['super_admin', 'hospital_admin', 'accountant'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member->branch_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class PatientNumberGenerator
|
||||||
|
{
|
||||||
|
public function generate(Organization $organization): string
|
||||||
|
{
|
||||||
|
$prefix = config('care.patient_number.prefix', 'LC');
|
||||||
|
$year = now()->format('Y');
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($organization, $prefix, $year) {
|
||||||
|
$latest = Patient::withTrashed()
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('patient_number', 'like', "{$prefix}-{$year}-%")
|
||||||
|
->orderByDesc('patient_number')
|
||||||
|
->lockForUpdate()
|
||||||
|
->value('patient_number');
|
||||||
|
|
||||||
|
$sequence = 1;
|
||||||
|
if ($latest && preg_match('/-(\d+)$/', $latest, $matches)) {
|
||||||
|
$sequence = (int) $matches[1] + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s-%s-%05d', $prefix, $year, $sequence);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\EmergencyContact;
|
||||||
|
use App\Models\InsurancePolicy;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\PatientAllergy;
|
||||||
|
use App\Models\PatientAttachment;
|
||||||
|
use App\Models\PatientCondition;
|
||||||
|
use App\Models\PatientFamilyHistory;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PatientService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PatientNumberGenerator $numbers,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return Patient::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function search(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->with(['branch'])
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($q = trim((string) ($filters['q'] ?? ''))) {
|
||||||
|
$query->where(function (Builder $inner) use ($q) {
|
||||||
|
$inner->where('patient_number', 'like', "%{$q}%")
|
||||||
|
->orWhere('phone', 'like', "%{$q}%")
|
||||||
|
->orWhere('national_id', 'like', "%{$q}%")
|
||||||
|
->orWhere('first_name', 'like', "%{$q}%")
|
||||||
|
->orWhere('last_name', 'like', "%{$q}%")
|
||||||
|
->orWhere('other_names', 'like', "%{$q}%")
|
||||||
|
->orWhereRaw("concat(first_name, ' ', coalesce(other_names, ''), ' ', last_name) like ?", ["%{$q}%"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientNumber = trim((string) ($filters['patient_number'] ?? ''))) {
|
||||||
|
$query->where('patient_number', $patientNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($phone = trim((string) ($filters['phone'] ?? ''))) {
|
||||||
|
$query->where('phone', 'like', "%{$phone}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nationalId = trim((string) ($filters['national_id'] ?? ''))) {
|
||||||
|
$query->where('national_id', $nationalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dob = $filters['date_of_birth'] ?? null) {
|
||||||
|
$query->whereDate('date_of_birth', Carbon::parse($dob)->toDateString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function create(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Patient
|
||||||
|
{
|
||||||
|
$patient = Patient::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $data['branch_id'] ?? null,
|
||||||
|
'patient_number' => $this->numbers->generate($organization),
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'other_names' => $data['other_names'] ?? null,
|
||||||
|
'gender' => $data['gender'] ?? null,
|
||||||
|
'date_of_birth' => $data['date_of_birth'] ?? null,
|
||||||
|
'phone' => $data['phone'] ?? null,
|
||||||
|
'email' => $data['email'] ?? null,
|
||||||
|
'national_id' => $data['national_id'] ?? null,
|
||||||
|
'address' => $data['address'] ?? null,
|
||||||
|
'city' => $data['city'] ?? null,
|
||||||
|
'region' => $data['region'] ?? null,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncRelated($patient, $ownerRef, $data);
|
||||||
|
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'patient.created', $organization->id, $actorRef, Patient::class, $patient->id);
|
||||||
|
|
||||||
|
return $patient->fresh([
|
||||||
|
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
|
||||||
|
'insurancePolicies', 'attachments', 'branch',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function update(Patient $patient, string $ownerRef, array $data, ?string $actorRef = null): Patient
|
||||||
|
{
|
||||||
|
$patient->update([
|
||||||
|
'branch_id' => $data['branch_id'] ?? $patient->branch_id,
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'other_names' => $data['other_names'] ?? null,
|
||||||
|
'gender' => $data['gender'] ?? null,
|
||||||
|
'date_of_birth' => $data['date_of_birth'] ?? null,
|
||||||
|
'phone' => $data['phone'] ?? null,
|
||||||
|
'email' => $data['email'] ?? null,
|
||||||
|
'national_id' => $data['national_id'] ?? null,
|
||||||
|
'address' => $data['address'] ?? null,
|
||||||
|
'city' => $data['city'] ?? null,
|
||||||
|
'region' => $data['region'] ?? null,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncRelated($patient, $ownerRef, $data);
|
||||||
|
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'patient.updated', $patient->organization_id, $actorRef, Patient::class, $patient->id);
|
||||||
|
|
||||||
|
return $patient->fresh([
|
||||||
|
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
|
||||||
|
'insurancePolicies', 'attachments', 'branch',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Patient $patient, string $ownerRef, ?string $actorRef = null): void
|
||||||
|
{
|
||||||
|
$organizationId = $patient->organization_id;
|
||||||
|
$patientId = $patient->id;
|
||||||
|
|
||||||
|
foreach ($patient->attachments as $attachment) {
|
||||||
|
Storage::disk('public')->delete($attachment->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$patient->delete();
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'patient.deleted', $organizationId, $actorRef, Patient::class, $patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function dashboard(Patient $patient): array
|
||||||
|
{
|
||||||
|
$patient->load([
|
||||||
|
'branch', 'allergies', 'conditions', 'familyHistory',
|
||||||
|
'emergencyContacts', 'insurancePolicies', 'attachments',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visits = $patient->visits()
|
||||||
|
->with(['branch'])
|
||||||
|
->orderByDesc('checked_in_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$consultations = Consultation::owned($patient->owner_ref)
|
||||||
|
->where('patient_id', $patient->id)
|
||||||
|
->with(['practitioner', 'diagnoses'])
|
||||||
|
->orderByDesc('started_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$laboratory = $patient->investigationRequests()
|
||||||
|
->with(['investigationType', 'result'])
|
||||||
|
->whereIn('status', [
|
||||||
|
\App\Models\InvestigationRequest::STATUS_COMPLETED,
|
||||||
|
\App\Models\InvestigationRequest::STATUS_DELIVERED,
|
||||||
|
])
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$prescriptions = $patient->prescriptions()
|
||||||
|
->with(['practitioner', 'items'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$invoices = $patient->bills()
|
||||||
|
->with(['branch', 'payments'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'patient' => $patient,
|
||||||
|
'visits' => $visits,
|
||||||
|
'consultations' => $consultations,
|
||||||
|
'laboratory' => $laboratory,
|
||||||
|
'prescriptions' => $prescriptions,
|
||||||
|
'invoices' => $invoices,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
protected function syncRelated(Patient $patient, string $ownerRef, array $data): void
|
||||||
|
{
|
||||||
|
if (array_key_exists('allergies', $data)) {
|
||||||
|
$patient->allergies()->delete();
|
||||||
|
foreach ($data['allergies'] as $row) {
|
||||||
|
if (empty($row['allergen'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PatientAllergy::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'allergen' => $row['allergen'],
|
||||||
|
'severity' => $row['severity'] ?? 'unknown',
|
||||||
|
'notes' => $row['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('conditions', $data)) {
|
||||||
|
$patient->conditions()->delete();
|
||||||
|
foreach ($data['conditions'] as $row) {
|
||||||
|
if (empty($row['condition'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PatientCondition::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'condition' => $row['condition'],
|
||||||
|
'onset_date' => $row['onset_date'] ?? null,
|
||||||
|
'is_chronic' => (bool) ($row['is_chronic'] ?? false),
|
||||||
|
'notes' => $row['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('family_history', $data)) {
|
||||||
|
$patient->familyHistory()->delete();
|
||||||
|
foreach ($data['family_history'] as $row) {
|
||||||
|
if (empty($row['condition'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PatientFamilyHistory::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'relation' => $row['relation'] ?? 'other',
|
||||||
|
'condition' => $row['condition'],
|
||||||
|
'notes' => $row['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('emergency_contacts', $data)) {
|
||||||
|
$patient->emergencyContacts()->delete();
|
||||||
|
foreach ($data['emergency_contacts'] as $row) {
|
||||||
|
if (empty($row['name']) || empty($row['phone'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
EmergencyContact::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'name' => $row['name'],
|
||||||
|
'phone' => $row['phone'],
|
||||||
|
'relationship' => $row['relationship'] ?? null,
|
||||||
|
'is_primary' => (bool) ($row['is_primary'] ?? false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('insurance', $data)) {
|
||||||
|
$patient->insurancePolicies()->delete();
|
||||||
|
foreach ($data['insurance'] as $row) {
|
||||||
|
if (empty($row['provider_name'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
InsurancePolicy::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'provider_name' => $row['provider_name'],
|
||||||
|
'policy_number' => $row['policy_number'] ?? null,
|
||||||
|
'coverage_type' => $row['coverage_type'] ?? null,
|
||||||
|
'expiry_date' => $row['expiry_date'] ?? null,
|
||||||
|
'notes' => $row['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, UploadedFile> $files
|
||||||
|
*/
|
||||||
|
protected function storeAttachments(Patient $patient, string $ownerRef, array $files, ?string $actorRef): void
|
||||||
|
{
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $file->store("care/patients/{$patient->id}/attachments", 'public');
|
||||||
|
|
||||||
|
PatientAttachment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'file_path' => $path,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
'document_type' => 'other',
|
||||||
|
'uploaded_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\DispensingRecord;
|
||||||
|
use App\Models\Drug;
|
||||||
|
use App\Models\DrugBatch;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Models\PrescriptionItem;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class PharmacyService
|
||||||
|
{
|
||||||
|
public function queryDrugs(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return Drug::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function listDrugs(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryDrugs($ownerRef, $organizationId)
|
||||||
|
->with(['batches'])
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->where(function (Builder $q) use ($branchId) {
|
||||||
|
$q->where('branch_id', $branchId)->orWhereNull('branch_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search = trim((string) ($filters['q'] ?? ''))) {
|
||||||
|
$query->where(function (Builder $inner) use ($search) {
|
||||||
|
$inner->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('generic_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('sku', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 30))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function createDrug(Organization $organization, string $ownerRef, array $data): Drug
|
||||||
|
{
|
||||||
|
$drug = Drug::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $data['branch_id'] ?? null,
|
||||||
|
'name' => $data['name'],
|
||||||
|
'generic_name' => $data['generic_name'] ?? null,
|
||||||
|
'sku' => $data['sku'] ?? null,
|
||||||
|
'unit' => $data['unit'] ?? 'unit',
|
||||||
|
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
|
||||||
|
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
|
||||||
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'drug.created', $organization->id, null, Drug::class, $drug->id);
|
||||||
|
|
||||||
|
return $drug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function updateDrug(Drug $drug, string $ownerRef, array $data): Drug
|
||||||
|
{
|
||||||
|
$drug->update([
|
||||||
|
'branch_id' => $data['branch_id'] ?? $drug->branch_id,
|
||||||
|
'name' => $data['name'],
|
||||||
|
'generic_name' => $data['generic_name'] ?? null,
|
||||||
|
'sku' => $data['sku'] ?? null,
|
||||||
|
'unit' => $data['unit'] ?? $drug->unit,
|
||||||
|
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
|
||||||
|
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
|
||||||
|
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'drug.updated', $drug->organization_id, null, Drug::class, $drug->id);
|
||||||
|
|
||||||
|
return $drug->fresh(['batches']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function receiveBatch(Drug $drug, string $ownerRef, array $data, ?string $actorRef = null): DrugBatch
|
||||||
|
{
|
||||||
|
$batch = DrugBatch::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'drug_id' => $drug->id,
|
||||||
|
'batch_number' => $data['batch_number'],
|
||||||
|
'expiry_date' => $data['expiry_date'] ?? null,
|
||||||
|
'quantity_on_hand' => (int) ($data['quantity_on_hand'] ?? 0),
|
||||||
|
'cost_minor' => (int) ($data['cost_minor'] ?? 0),
|
||||||
|
'received_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'drug.batch_received', $drug->organization_id, $actorRef, DrugBatch::class, $batch->id);
|
||||||
|
|
||||||
|
return $batch->load('drug');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Drug>
|
||||||
|
*/
|
||||||
|
public function lowStock(string $ownerRef, int $organizationId): Collection
|
||||||
|
{
|
||||||
|
return $this->queryDrugs($ownerRef, $organizationId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('batches')
|
||||||
|
->get()
|
||||||
|
->filter(fn (Drug $drug) => $drug->stockOnHand() <= $drug->reorder_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, DrugBatch>
|
||||||
|
*/
|
||||||
|
public function expiredBatches(string $ownerRef, int $organizationId): Collection
|
||||||
|
{
|
||||||
|
return DrugBatch::owned($ownerRef)
|
||||||
|
->whereHas('drug', fn (Builder $q) => $q->where('organization_id', $organizationId))
|
||||||
|
->whereNotNull('expiry_date')
|
||||||
|
->where('expiry_date', '<', now()->toDateString())
|
||||||
|
->where('quantity_on_hand', '>', 0)
|
||||||
|
->with('drug')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $allocations
|
||||||
|
*/
|
||||||
|
public function dispensePrescription(
|
||||||
|
Prescription $prescription,
|
||||||
|
string $ownerRef,
|
||||||
|
array $allocations,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Prescription {
|
||||||
|
if ($prescription->status !== Prescription::STATUS_ACTIVE) {
|
||||||
|
throw new InvalidArgumentException('Only active prescriptions can be dispensed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prescription->load('items');
|
||||||
|
|
||||||
|
foreach ($allocations as $row) {
|
||||||
|
if (empty($row['prescription_item_id']) || empty($row['drug_batch_id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = $prescription->items->firstWhere('id', (int) $row['prescription_item_id']);
|
||||||
|
if (! $item || $item->is_procedure) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qty = max(1, (int) ($row['quantity'] ?? 1));
|
||||||
|
$batch = DrugBatch::owned($ownerRef)->findOrFail((int) $row['drug_batch_id']);
|
||||||
|
|
||||||
|
if ($batch->isExpired()) {
|
||||||
|
throw new InvalidArgumentException("Batch {$batch->batch_number} is expired.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($batch->quantity_on_hand < $qty) {
|
||||||
|
throw new InvalidArgumentException("Insufficient stock for {$batch->drug->name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch->decrement('quantity_on_hand', $qty);
|
||||||
|
|
||||||
|
DispensingRecord::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'prescription_id' => $prescription->id,
|
||||||
|
'prescription_item_id' => $item->id,
|
||||||
|
'drug_batch_id' => $batch->id,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'dispensed_by' => $actorRef,
|
||||||
|
'dispensed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $item->drug_id) {
|
||||||
|
$item->update(['drug_id' => $batch->drug_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$prescription->update([
|
||||||
|
'status' => Prescription::STATUS_DISPENSED,
|
||||||
|
'dispensed_at' => now(),
|
||||||
|
'dispensed_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
|
||||||
|
class PlanService
|
||||||
|
{
|
||||||
|
public function plan(Organization $organization): string
|
||||||
|
{
|
||||||
|
return (string) data_get($organization->settings, 'plan', 'free');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPro(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return $this->plan($organization) === 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxBranches(Organization $organization): ?int
|
||||||
|
{
|
||||||
|
$plan = config('care.plans.'.$this->plan($organization));
|
||||||
|
|
||||||
|
return $plan['max_branches'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAddBranch(Organization $organization, int $currentCount): bool
|
||||||
|
{
|
||||||
|
$max = $this->maxBranches($organization);
|
||||||
|
|
||||||
|
return $max === null || $currentCount < $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function proPriceMinor(): int
|
||||||
|
{
|
||||||
|
return (int) config('care.plans.pro.price_minor', 15000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Prescription;
|
||||||
|
use App\Models\PrescriptionItem;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class PrescriptionService
|
||||||
|
{
|
||||||
|
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
|
||||||
|
{
|
||||||
|
return Prescription::owned($ownerRef)->where('organization_id', $organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->with(['patient', 'practitioner', 'items'])
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($status = $filters['status'] ?? null) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patientId = $filters['patient_id'] ?? null) {
|
||||||
|
$query->where('patient_id', $patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($branchId !== null) {
|
||||||
|
$query->whereHas('visit', fn (Builder $q) => $q->where('branch_id', $branchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Prescription>
|
||||||
|
*/
|
||||||
|
public function pharmacyQueue(string $ownerRef, int $organizationId): Collection
|
||||||
|
{
|
||||||
|
return $this->queryForOrganization($ownerRef, $organizationId)
|
||||||
|
->where('status', Prescription::STATUS_ACTIVE)
|
||||||
|
->with(['patient', 'practitioner', 'items', 'visit.branch'])
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function createFromConsultation(
|
||||||
|
Consultation $consultation,
|
||||||
|
string $ownerRef,
|
||||||
|
array $data,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Prescription {
|
||||||
|
$consultation->loadMissing('visit');
|
||||||
|
|
||||||
|
$status = ($data['activate'] ?? true) ? Prescription::STATUS_ACTIVE : Prescription::STATUS_DRAFT;
|
||||||
|
|
||||||
|
$prescription = Prescription::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $consultation->visit->organization_id,
|
||||||
|
'visit_id' => $consultation->visit_id,
|
||||||
|
'consultation_id' => $consultation->id,
|
||||||
|
'patient_id' => $consultation->patient_id,
|
||||||
|
'practitioner_id' => $data['practitioner_id'] ?? $consultation->practitioner_id,
|
||||||
|
'status' => $status,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
'prescribed_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncItems($prescription, $ownerRef, $data['items'] ?? []);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'prescription.created',
|
||||||
|
$consultation->visit->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
Prescription::class,
|
||||||
|
$prescription->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function update(Prescription $prescription, string $ownerRef, array $data, ?string $actorRef = null): Prescription
|
||||||
|
{
|
||||||
|
if ($prescription->status !== Prescription::STATUS_DRAFT) {
|
||||||
|
throw new InvalidArgumentException('Only draft prescriptions can be edited.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prescription->update([
|
||||||
|
'practitioner_id' => $data['practitioner_id'] ?? $prescription->practitioner_id,
|
||||||
|
'notes' => $data['notes'] ?? $prescription->notes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (array_key_exists('items', $data)) {
|
||||||
|
$this->syncItems($prescription, $ownerRef, $data['items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'prescription.updated', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient', 'practitioner']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
|
||||||
|
{
|
||||||
|
$this->assertStatus($prescription, Prescription::STATUS_DRAFT);
|
||||||
|
|
||||||
|
$prescription->update(['status' => Prescription::STATUS_ACTIVE]);
|
||||||
|
AuditLogger::record($ownerRef, 'prescription.activated', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispense(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
|
||||||
|
{
|
||||||
|
$this->assertStatus($prescription, Prescription::STATUS_ACTIVE);
|
||||||
|
|
||||||
|
$prescription->update([
|
||||||
|
'status' => Prescription::STATUS_DISPENSED,
|
||||||
|
'dispensed_at' => now(),
|
||||||
|
'dispensed_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
|
||||||
|
{
|
||||||
|
if (in_array($prescription->status, [Prescription::STATUS_DISPENSED, Prescription::STATUS_CANCELLED], true)) {
|
||||||
|
throw new InvalidArgumentException('Prescription cannot be cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prescription->update(['status' => Prescription::STATUS_CANCELLED]);
|
||||||
|
AuditLogger::record($ownerRef, 'prescription.cancelled', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
|
||||||
|
|
||||||
|
return $prescription->fresh(['items', 'patient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
protected function syncItems(Prescription $prescription, string $ownerRef, array $items): void
|
||||||
|
{
|
||||||
|
$prescription->items()->delete();
|
||||||
|
|
||||||
|
foreach ($items as $index => $row) {
|
||||||
|
if (empty($row['name'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PrescriptionItem::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'prescription_id' => $prescription->id,
|
||||||
|
'is_procedure' => (bool) ($row['is_procedure'] ?? false),
|
||||||
|
'name' => $row['name'],
|
||||||
|
'dosage' => $row['dosage'] ?? null,
|
||||||
|
'frequency' => $row['frequency'] ?? null,
|
||||||
|
'duration' => $row['duration'] ?? null,
|
||||||
|
'route' => $row['route'] ?? null,
|
||||||
|
'quantity' => $row['quantity'] ?? null,
|
||||||
|
'instructions' => $row['instructions'] ?? null,
|
||||||
|
'sort_order' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertStatus(Prescription $prescription, string ...$allowed): void
|
||||||
|
{
|
||||||
|
if (! in_array($prescription->status, $allowed, true)) {
|
||||||
|
throw new InvalidArgumentException("Cannot transition from {$prescription->status}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Appointment;
|
||||||
|
use App\Models\Bill;
|
||||||
|
use App\Models\Diagnosis;
|
||||||
|
use App\Models\InvestigationRequest;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\Visit;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ReportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function dashboardStats(string $ownerRef, int $organizationId, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
|
||||||
|
$patientsToday = Patient::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereDate('created_at', $today)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$appointmentsToday = Appointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereDate('scheduled_at', $today)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$openBills = Bill::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$revenueToday = Payment::owned($ownerRef)
|
||||||
|
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
|
||||||
|
$q->where('organization_id', $organizationId);
|
||||||
|
if ($branchId) {
|
||||||
|
$q->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->whereDate('paid_at', $today)
|
||||||
|
->sum('amount_minor');
|
||||||
|
|
||||||
|
$pendingLab = InvestigationRequest::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereIn('status', InvestigationRequest::activeStatuses())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'patients_today' => $patientsToday,
|
||||||
|
'appointments_today' => $appointmentsToday,
|
||||||
|
'open_bills' => $openBills,
|
||||||
|
'revenue_today_minor' => (int) $revenueToday,
|
||||||
|
'pending_lab' => $pendingLab,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function patientsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$newPatients = Patient::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('created_at', [$from, $to])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$returningPatients = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('checked_in_at', [$from, $to])
|
||||||
|
->distinct('patient_id')
|
||||||
|
->count('patient_id');
|
||||||
|
|
||||||
|
$visits = Visit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('checked_in_at', [$from, $to])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'new_patients' => $newPatients,
|
||||||
|
'returning_patients' => $returningPatients,
|
||||||
|
'total_visits' => $visits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = Appointment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('scheduled_at', [$from, $to]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $base)->count(),
|
||||||
|
'completed' => (clone $base)->where('status', Appointment::STATUS_COMPLETED)->count(),
|
||||||
|
'cancelled' => (clone $base)->where('status', Appointment::STATUS_CANCELLED)->count(),
|
||||||
|
'no_show' => (clone $base)->where('status', Appointment::STATUS_NO_SHOW)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function laboratoryReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$base = InvestigationRequest::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
|
$completed = (clone $base)
|
||||||
|
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$turnaroundHours = $completed
|
||||||
|
->filter(fn ($r) => $r->completed_at && $r->created_at)
|
||||||
|
->map(fn ($r) => $r->created_at->diffInHours($r->completed_at))
|
||||||
|
->avg();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'requested' => (clone $base)->count(),
|
||||||
|
'completed' => $completed->count(),
|
||||||
|
'pending' => (clone $base)->whereIn('status', InvestigationRequest::activeStatuses())->count(),
|
||||||
|
'avg_turnaround_hours' => $turnaroundHours ? round($turnaroundHours, 1) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function financeReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||||
|
{
|
||||||
|
$billed = Bill::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereBetween('created_at', [$from, $to])
|
||||||
|
->where('status', '!=', Bill::STATUS_VOID);
|
||||||
|
|
||||||
|
$payments = Payment::owned($ownerRef)
|
||||||
|
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
|
||||||
|
$q->where('organization_id', $organizationId);
|
||||||
|
if ($branchId) {
|
||||||
|
$q->where('branch_id', $branchId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->whereBetween('paid_at', [$from, $to]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invoiced_minor' => (int) (clone $billed)->sum('total_minor'),
|
||||||
|
'collected_minor' => (int) (clone $payments)->sum('amount_minor'),
|
||||||
|
'outstanding_minor' => (int) Bill::owned($ownerRef)
|
||||||
|
->where('organization_id', $organizationId)
|
||||||
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||||
|
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
|
||||||
|
->sum('balance_minor'),
|
||||||
|
'invoice_count' => (clone $billed)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, object>
|
||||||
|
*/
|
||||||
|
public function clinicalReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to): Collection
|
||||||
|
{
|
||||||
|
return Diagnosis::owned($ownerRef)
|
||||||
|
->whereHas('consultation.visit', function (Builder $q) use ($organizationId, $from, $to) {
|
||||||
|
$q->where('organization_id', $organizationId)
|
||||||
|
->whereBetween('checked_in_at', [$from, $to]);
|
||||||
|
})
|
||||||
|
->select('description', DB::raw('count(*) as total'))
|
||||||
|
->groupBy('description')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Patient;
|
||||||
|
use App\Models\Visit;
|
||||||
|
|
||||||
|
class VisitService
|
||||||
|
{
|
||||||
|
public function checkIn(
|
||||||
|
Organization $organization,
|
||||||
|
string $ownerRef,
|
||||||
|
Patient $patient,
|
||||||
|
int $branchId,
|
||||||
|
?string $actorRef = null,
|
||||||
|
): Visit {
|
||||||
|
$visit = Visit::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'branch_id' => $branchId,
|
||||||
|
'patient_id' => $patient->id,
|
||||||
|
'status' => Visit::STATUS_OPEN,
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
'checked_in_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'visit.checked_in', $organization->id, $actorRef, Visit::class, $visit->id);
|
||||||
|
|
||||||
|
return $visit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Visit $visit, string $ownerRef, ?string $actorRef = null): Visit
|
||||||
|
{
|
||||||
|
$visit->update([
|
||||||
|
'status' => Visit::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record($ownerRef, 'visit.completed', $visit->organization_id, $actorRef, Visit::class, $visit->id);
|
||||||
|
|
||||||
|
return $visit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Comms;
|
||||||
|
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
|
||||||
|
* a Ladill Bird SMTP credential in production so mail leaves as the account's
|
||||||
|
* sender). Failures are swallowed + logged; the caller decides what to record.
|
||||||
|
*/
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
|
||||||
|
{
|
||||||
|
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user