Add specialty module and GP service pricing settings.
Deploy Ladill Care / deploy (push) Successful in 51s
Deploy Ladill Care / deploy (push) Successful in 51s
Admin dashboard module cards open per-module settings with editable service prices; GP consultation and clinic fees live under Settings → GP pricing (inventory drugs unchanged). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\CarePricingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsGpPricingController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function edit(Request $request, CarePricingService $pricing): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
|
||||
|
||||
return view('care.settings.gp-pricing', [
|
||||
'organization' => $organization,
|
||||
'services' => $pricing->gpServices($organization),
|
||||
'canManage' => $canManage,
|
||||
'currency' => strtoupper((string) config('care.billing.currency', 'GHS')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, CarePricingService $pricing): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'services' => ['required', 'array'],
|
||||
'services.*.code' => ['required', 'string', 'max:64'],
|
||||
'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'],
|
||||
]);
|
||||
|
||||
$pricing->updateGpServices($organization, $validated['services']);
|
||||
|
||||
return redirect()
|
||||
->route('care.settings.gp-pricing')
|
||||
->with('success', 'GP service prices saved.');
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\CarePricingService;
|
||||
use App\Services\Care\PlanService;
|
||||
use App\Services\Care\SpecialtyModuleService;
|
||||
use App\Services\Care\SpecialtyShellService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -19,7 +22,7 @@ class SettingsModulesController extends Controller
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
$canManage = app(\App\Services\Care\CarePermissions::class)->can($member, 'settings.manage');
|
||||
$canManage = app(CarePermissions::class)->can($member, 'settings.manage');
|
||||
|
||||
if ($modules->canManage($organization)) {
|
||||
$modules->ensureDefaultModulesProvisioned($organization, $this->ownerRef($request));
|
||||
@@ -36,6 +39,71 @@ class SettingsModulesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(
|
||||
Request $request,
|
||||
string $module,
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
CarePricingService $pricing,
|
||||
): View {
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$definition = $modules->definition($module);
|
||||
abort_unless($definition, 404);
|
||||
|
||||
$organization = $this->organization($request);
|
||||
$member = $this->member($request);
|
||||
$canManage = app(CarePermissions::class)->can($member, 'settings.manage');
|
||||
$canUse = $modules->canManage($organization);
|
||||
|
||||
if ($canUse && $modules->isEnabled($organization, $module)) {
|
||||
$shell->seedServices($organization, $module);
|
||||
$organization->refresh();
|
||||
}
|
||||
|
||||
$services = $canUse
|
||||
? $shell->provisionedServices($organization, $module)
|
||||
: $shell->catalogServices($module);
|
||||
|
||||
return view('care.settings.module-show', [
|
||||
'organization' => $organization,
|
||||
'moduleKey' => $module,
|
||||
'definition' => $definition,
|
||||
'services' => $services,
|
||||
'enabled' => $modules->isEnabled($organization, $module),
|
||||
'canManage' => $canManage && $canUse,
|
||||
'canUseSpecialtyModules' => $canUse,
|
||||
'canOpenWorkspace' => $modules->memberCanAccess($organization, $member, $module),
|
||||
'currency' => strtoupper((string) config('care.billing.currency', 'GHS')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateServices(
|
||||
Request $request,
|
||||
string $module,
|
||||
SpecialtyModuleService $modules,
|
||||
SpecialtyShellService $shell,
|
||||
CarePricingService $pricing,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
abort_unless($modules->definition($module), 404);
|
||||
abort_unless($modules->canManage($organization), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'services' => ['required', 'array'],
|
||||
'services.*.code' => ['required', 'string', 'max:64'],
|
||||
'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'],
|
||||
'services.*.label' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$shell->seedServices($organization, $module);
|
||||
$pricing->updateSpecialtyServices($organization->fresh(), $module, $validated['services'], $shell);
|
||||
|
||||
return redirect()
|
||||
->route('care.settings.modules.show', $module)
|
||||
->with('success', 'Service prices saved.');
|
||||
}
|
||||
|
||||
public function update(Request $request, SpecialtyModuleService $modules): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
|
||||
@@ -171,7 +171,10 @@ class BillService
|
||||
|
||||
if (! in_array('consultation', $workflowChargeCodes, true)
|
||||
&& $visit->consultations()->where('status', 'completed')->exists()) {
|
||||
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
|
||||
$org = Organization::query()->find($visit->organization_id);
|
||||
$fee = $org
|
||||
? app(CarePricingService::class)->consultationFee($org)
|
||||
: (int) config('care.billing.consultation_fee_minor', 5000);
|
||||
$this->addLineItem($bill, $ownerRef, [
|
||||
'type' => 'consultation',
|
||||
'description' => 'Consultation fee',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* Org-level price lists for GP (general practice) and specialty module services.
|
||||
* Drug prices stay on Inventory (care_drugs.unit_price_minor); lab prices on investigation types.
|
||||
*/
|
||||
class CarePricingService
|
||||
{
|
||||
/**
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function defaultGpServices(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'code' => 'gp.consultation',
|
||||
'label' => 'GP consultation',
|
||||
'amount_minor' => (int) config('care.billing.consultation_fee_minor', 5000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.follow_up',
|
||||
'label' => 'Follow-up consultation',
|
||||
'amount_minor' => (int) config('care.billing.follow_up_fee_minor', 3000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.patient_card',
|
||||
'label' => 'Patient card / registration',
|
||||
'amount_minor' => (int) config('care.billing.patient_card_fee_minor', 2000),
|
||||
'type' => 'misc',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.vitals',
|
||||
'label' => 'Vitals / nursing charge',
|
||||
'amount_minor' => (int) config('care.billing.vitals_fee_minor', 1000),
|
||||
'type' => 'nursing',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.procedure',
|
||||
'label' => 'Minor procedure',
|
||||
'amount_minor' => (int) config('care.billing.minor_procedure_fee_minor', 5000),
|
||||
'type' => 'procedure',
|
||||
],
|
||||
[
|
||||
'code' => 'gp.home_visit',
|
||||
'label' => 'Home visit',
|
||||
'amount_minor' => (int) config('care.billing.home_visit_fee_minor', 15000),
|
||||
'type' => 'consultation',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function gpServices(Organization $organization): array
|
||||
{
|
||||
$catalog = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']);
|
||||
$stored = data_get($organization->settings, 'gp_services');
|
||||
if (is_array($stored)) {
|
||||
foreach ($stored as $service) {
|
||||
$code = (string) ($service['code'] ?? '');
|
||||
if ($code === '' || ! $catalog->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$prior = $catalog->get($code, []);
|
||||
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], [
|
||||
'code' => $code,
|
||||
'label' => (string) ($service['label'] ?? $prior['label'] ?? $code),
|
||||
'amount_minor' => (int) ($service['amount_minor'] ?? $prior['amount_minor'] ?? 0),
|
||||
'type' => (string) ($service['type'] ?? $prior['type'] ?? 'misc'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return $catalog->values()->all();
|
||||
}
|
||||
|
||||
public function gpAmount(Organization $organization, string $code): int
|
||||
{
|
||||
$match = collect($this->gpServices($organization))
|
||||
->first(fn ($s) => ($s['code'] ?? '') === $code);
|
||||
|
||||
return (int) ($match['amount_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function consultationFee(Organization $organization): int
|
||||
{
|
||||
return $this->gpAmount($organization, 'gp.consultation');
|
||||
}
|
||||
|
||||
public function patientCardFee(Organization $organization): int
|
||||
{
|
||||
return $this->gpAmount($organization, 'gp.patient_card');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, amount_minor: int|string, label?: string}> $rows
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function updateGpServices(Organization $organization, array $rows): array
|
||||
{
|
||||
$defaults = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']);
|
||||
$merged = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = (string) ($row['code'] ?? '');
|
||||
if ($code === '' || ! $defaults->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$base = $defaults->get($code);
|
||||
$merged[] = [
|
||||
'code' => $code,
|
||||
'label' => (string) ($base['label'] ?? $code),
|
||||
'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)),
|
||||
'type' => (string) ($base['type'] ?? 'misc'),
|
||||
];
|
||||
}
|
||||
|
||||
// Keep any defaults not submitted.
|
||||
$byCode = collect($merged)->keyBy('code');
|
||||
foreach ($defaults as $code => $base) {
|
||||
if (! $byCode->has($code)) {
|
||||
$byCode->put($code, $base);
|
||||
}
|
||||
}
|
||||
|
||||
$list = $byCode->values()->all();
|
||||
$settings = $organization->settings ?? [];
|
||||
$settings['gp_services'] = $list;
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist specialty module service amounts (major currency input → minor).
|
||||
*
|
||||
* @param list<array{code: string, amount?: float|int|string, label?: string}> $rows
|
||||
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
|
||||
*/
|
||||
public function updateSpecialtyServices(
|
||||
Organization $organization,
|
||||
string $moduleKey,
|
||||
array $rows,
|
||||
SpecialtyShellService $shell,
|
||||
): array {
|
||||
$current = collect($shell->provisionedServices($organization, $moduleKey))
|
||||
->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = (string) ($row['code'] ?? '');
|
||||
if ($code === '' || ! $current->has($code)) {
|
||||
continue;
|
||||
}
|
||||
$prior = $current->get($code);
|
||||
$current->put($code, array_merge(is_array($prior) ? $prior : [], [
|
||||
'code' => $code,
|
||||
'label' => (string) ($row['label'] ?? $prior['label'] ?? $code),
|
||||
'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)),
|
||||
'type' => (string) ($prior['type'] ?? 'misc'),
|
||||
]));
|
||||
}
|
||||
|
||||
$list = $current->filter(fn ($s, $code) => $code !== '')->values()->all();
|
||||
$settings = $organization->settings ?? [];
|
||||
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
|
||||
? $settings['specialty_module_provisioning']
|
||||
: [];
|
||||
$record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : [];
|
||||
$record['services'] = $list;
|
||||
$provisioning[$moduleKey] = $record;
|
||||
$settings['specialty_module_provisioning'] = $provisioning;
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Prescription;
|
||||
use App\Models\Visit;
|
||||
use App\Models\WorkflowStage;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePricingService;
|
||||
|
||||
/**
|
||||
* Owns FinancialObligation lifecycle and answers whether a stage's financial
|
||||
@@ -154,9 +155,16 @@ class FinancialGateService
|
||||
|
||||
protected function defaultAmountFor(WorkflowStage $stage, Visit $visit): int
|
||||
{
|
||||
$org = Organization::query()->find($visit->organization_id);
|
||||
$pricing = app(CarePricingService::class);
|
||||
|
||||
return match ($stage->charge_code) {
|
||||
'patient_card', 'registration' => (int) config('care.billing.patient_card_fee_minor', 0),
|
||||
'consultation' => (int) config('care.billing.consultation_fee_minor', 0),
|
||||
'patient_card', 'registration' => $org
|
||||
? $pricing->patientCardFee($org)
|
||||
: (int) config('care.billing.patient_card_fee_minor', 0),
|
||||
'consultation' => $org
|
||||
? $pricing->consultationFee($org)
|
||||
: (int) config('care.billing.consultation_fee_minor', 0),
|
||||
'lab' => $this->investigationTotal($visit, imaging: false),
|
||||
'imaging' => $this->investigationTotal($visit, imaging: true),
|
||||
'pharmacy' => $this->pharmacyTotal($visit),
|
||||
|
||||
Reference in New Issue
Block a user