Работа над журналом для ст. мед сестер
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\DepartmentPatient;
|
||||
use App\Models\DepartmentPatientOperation;
|
||||
use App\Models\MedicalHistorySnapshot;
|
||||
use App\Models\MisServiceMedical;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use App\Services\DateRangeService;
|
||||
use App\Services\UnifiedPatientService;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Управляет manual/special пациентами отчёта и их операциями.
|
||||
*
|
||||
* Сервис держит orchestration вокруг legacy UnifiedPatientService, пока
|
||||
* patient-write сценарии постепенно выносятся из ReportService.
|
||||
*/
|
||||
class ManualPatientManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DateRangeService $dateRangeService,
|
||||
private readonly UnifiedPatientService $unifiedPatientService,
|
||||
private readonly ReportReadContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createManualPatient(Department $department, User $user, array $data): DepartmentPatient
|
||||
{
|
||||
$report = $this->resolveReportForManualPatient($department, $user, $data);
|
||||
|
||||
return $this->unifiedPatientService->createManualPatient($department, $user, $data, $report->report_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function setManualPatientOutcome(User $user, int $departmentPatientId, array $data): DepartmentPatient
|
||||
{
|
||||
$patient = DepartmentPatient::query()
|
||||
->where('department_patient_id', $departmentPatientId)
|
||||
->firstOrFail();
|
||||
|
||||
$updatedPatient = $this->unifiedPatientService->recordManualOutcome($patient, $data);
|
||||
$this->syncManualPatientSnapshots($updatedPatient, $user, []);
|
||||
|
||||
return $updatedPatient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateManualPatient(User $user, int $departmentPatientId, array $data): DepartmentPatient
|
||||
{
|
||||
$patient = $this->resolveManageableManualPatient($user, $departmentPatientId);
|
||||
|
||||
$updatedPatient = $this->unifiedPatientService->updateManualPatient($patient, $data);
|
||||
$this->syncManualPatientSnapshots($updatedPatient, $user, $data);
|
||||
|
||||
return $updatedPatient;
|
||||
}
|
||||
|
||||
public function linkManualPatientToMis(int $departmentPatientId, int $medicalHistoryId): DepartmentPatient
|
||||
{
|
||||
$patient = DepartmentPatient::query()
|
||||
->where('department_patient_id', $departmentPatientId)
|
||||
->firstOrFail();
|
||||
|
||||
return $this->unifiedPatientService->linkManualPatientToMis($patient, $medicalHistoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, DepartmentPatientOperation>
|
||||
*/
|
||||
public function getManualPatientOperations(User $user, int $departmentPatientId): Collection
|
||||
{
|
||||
$patient = $this->resolveManageableManualPatient($user, $departmentPatientId);
|
||||
|
||||
return $patient->operations()
|
||||
->with('serviceMedical')
|
||||
->orderByDesc('started_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createManualPatientOperation(
|
||||
User $user,
|
||||
int $departmentPatientId,
|
||||
array $data
|
||||
): DepartmentPatientOperation {
|
||||
$patient = $this->resolveManageableManualPatient($user, $departmentPatientId);
|
||||
$service = $this->resolveMedicalService((int) $data['service_id']);
|
||||
|
||||
return $patient->operations()->create([
|
||||
'rf_kl_service_medical_id' => $service->ServiceMedicalID,
|
||||
'service_code' => $service->ServiceMedicalCode,
|
||||
'service_name' => $service->ServiceMedicalName,
|
||||
'urgency' => $data['urgency'],
|
||||
'started_at' => $data['started_at'],
|
||||
'ended_at' => $data['ended_at'],
|
||||
'created_by' => $user->id,
|
||||
])->load('serviceMedical');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateManualPatientOperation(
|
||||
User $user,
|
||||
int $departmentPatientId,
|
||||
int $operationId,
|
||||
array $data
|
||||
): DepartmentPatientOperation {
|
||||
$patient = $this->resolveManageableManualPatient($user, $departmentPatientId);
|
||||
$service = $this->resolveMedicalService((int) $data['service_id']);
|
||||
|
||||
$operation = $patient->operations()
|
||||
->where('department_patient_operation_id', $operationId)
|
||||
->firstOrFail();
|
||||
|
||||
$operation->update([
|
||||
'rf_kl_service_medical_id' => $service->ServiceMedicalID,
|
||||
'service_code' => $service->ServiceMedicalCode,
|
||||
'service_name' => $service->ServiceMedicalName,
|
||||
'urgency' => $data['urgency'],
|
||||
'started_at' => $data['started_at'],
|
||||
'ended_at' => $data['ended_at'],
|
||||
]);
|
||||
|
||||
return $operation->fresh()->load('serviceMedical');
|
||||
}
|
||||
|
||||
public function deleteManualPatientOperation(User $user, int $departmentPatientId, int $operationId): void
|
||||
{
|
||||
$patient = $this->resolveManageableManualPatient($user, $departmentPatientId);
|
||||
|
||||
$patient->operations()
|
||||
->where('department_patient_operation_id', $operationId)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function resolveManageableManualPatient(User $user, int $departmentPatientId): DepartmentPatient
|
||||
{
|
||||
$query = DepartmentPatient::query()
|
||||
->where('department_patient_id', $departmentPatientId)
|
||||
->whereIn('source_type', ['manual', 'special']);
|
||||
|
||||
if (! $user->isAdmin() && ! $user->isHeadOfDepartment()) {
|
||||
$query->where('rf_department_id', $user->department->department_id);
|
||||
}
|
||||
|
||||
return $query->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function syncManualPatientSnapshots(DepartmentPatient $patient, User $user, array $data): void
|
||||
{
|
||||
$reportIds = $patient->rf_report_id
|
||||
? [$patient->rf_report_id]
|
||||
: (isset($data['startAt'], $data['endAt']) && $data['startAt'] && $data['endAt']
|
||||
? $this->contextResolver
|
||||
->getReportsForDateRange(
|
||||
$patient->rf_department_id,
|
||||
$this->dateRangeService->getNormalizedDateRange(
|
||||
$user,
|
||||
(string) $data['startAt'],
|
||||
(string) $data['endAt']
|
||||
)
|
||||
)
|
||||
->pluck('report_id')
|
||||
->values()
|
||||
->all()
|
||||
: []);
|
||||
|
||||
if (empty($reportIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
MedicalHistorySnapshot::query()
|
||||
->whereIn('rf_report_id', $reportIds)
|
||||
->where('rf_department_patient_id', $patient->department_patient_id)
|
||||
->update([
|
||||
'patient_kind' => $patient->patient_kind,
|
||||
'full_name' => $patient->full_name,
|
||||
'birth_date' => $patient->birth_date,
|
||||
'diagnosis_code' => $patient->diagnosis_code,
|
||||
'diagnosis_name' => $patient->diagnosis_name,
|
||||
'admitted_at' => $patient->admitted_at,
|
||||
'outcome_type' => $patient->is_current ? null : $patient->outcome_type,
|
||||
'outcome_at' => $patient->is_current ? null : $patient->outcome_at,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function resolveReportForManualPatient(Department $department, User $user, array $data): Report
|
||||
{
|
||||
$reportId = $data['report_id'] ?? null;
|
||||
if ($reportId) {
|
||||
return Report::query()
|
||||
->where('report_id', $reportId)
|
||||
->where('rf_department_id', $department->department_id)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
if (! isset($data['startAt'], $data['endAt']) || ! $data['startAt'] || ! $data['endAt']) {
|
||||
throw new InvalidArgumentException('Не указан отчет или диапазон для привязки спецконтингента');
|
||||
}
|
||||
|
||||
$dateRange = $this->dateRangeService->getNormalizedDateRange(
|
||||
$user,
|
||||
(string) $data['startAt'],
|
||||
(string) $data['endAt']
|
||||
);
|
||||
|
||||
$existingReport = Report::query()
|
||||
->where('rf_department_id', $department->department_id)
|
||||
->exactPeriod($dateRange->startSql(), $dateRange->endSql())
|
||||
->first();
|
||||
|
||||
if ($existingReport) {
|
||||
return $existingReport;
|
||||
}
|
||||
|
||||
return Report::query()->create([
|
||||
'rf_department_id' => $department->department_id,
|
||||
'rf_user_id' => $user->id,
|
||||
'rf_lpudoctor_id' => $data['user_id'] ?? $user->rf_lpudoctor_id,
|
||||
'sent_at' => $dateRange->endSql(),
|
||||
'created_at' => $dateRange->endSql(),
|
||||
'period_start' => $dateRange->startSql(),
|
||||
'period_end' => $dateRange->endSql(),
|
||||
'status' => 'draft',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveMedicalService(int $serviceId): MisServiceMedical
|
||||
{
|
||||
return MisServiceMedical::query()
|
||||
->where('ServiceMedicalID', $serviceId)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Models\ObservationPatient;
|
||||
|
||||
/**
|
||||
* Управляет пациентами, находящимися на контроле в отчётном интерфейсе.
|
||||
*/
|
||||
class ObservationPatientManagementService
|
||||
{
|
||||
public function removeObservationPatient(string $patientId): void
|
||||
{
|
||||
[$sourceType, $id] = explode(':', $patientId) + [null, null];
|
||||
|
||||
if ($sourceType === 'manual') {
|
||||
ObservationPatient::query()
|
||||
->where('rf_department_patient_id', $id)
|
||||
->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ObservationPatient::query()
|
||||
->where('rf_medicalhistory_id', $id)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Models\ReanimationPatientIndicator;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Управляет индикаторами пациентов реанимации для отчётного интерфейса.
|
||||
*/
|
||||
class ReanimationIndicatorService
|
||||
{
|
||||
public function save(
|
||||
User $user,
|
||||
int $departmentId,
|
||||
int $medicalHistoryId,
|
||||
string $indicator,
|
||||
?string $comment = null,
|
||||
?int $reportId = null
|
||||
): ReanimationPatientIndicator {
|
||||
return ReanimationPatientIndicator::query()->create([
|
||||
'rf_department_id' => $departmentId,
|
||||
'rf_report_id' => $reportId,
|
||||
'rf_medicalhistory_id' => $medicalHistoryId,
|
||||
'indicator' => $indicator,
|
||||
'comment' => $comment,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $medicalHistoryIds
|
||||
* @return Collection<int, ReanimationPatientIndicator>
|
||||
*/
|
||||
public function latestByMedicalHistory(int $departmentId, array $medicalHistoryIds): Collection
|
||||
{
|
||||
if (empty($medicalHistoryIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$subQuery = ReanimationPatientIndicator::query()
|
||||
->selectRaw('MAX(reanimation_patient_indicator_id) as max_id, rf_medicalhistory_id')
|
||||
->where('rf_department_id', $departmentId)
|
||||
->whereIn('rf_medicalhistory_id', $medicalHistoryIds)
|
||||
->groupBy('rf_medicalhistory_id');
|
||||
|
||||
return ReanimationPatientIndicator::query()
|
||||
->joinSub($subQuery, 'latest', function ($join) {
|
||||
$join->on('reanimation_patient_indicators.reanimation_patient_indicator_id', '=', 'latest.max_id');
|
||||
})
|
||||
->get([
|
||||
'reanimation_patient_indicators.rf_medicalhistory_id',
|
||||
'reanimation_patient_indicators.indicator',
|
||||
'reanimation_patient_indicators.comment',
|
||||
])
|
||||
->keyBy('rf_medicalhistory_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ReanimationPatientIndicator>
|
||||
*/
|
||||
public function history(int $departmentId, int $medicalHistoryId, int $limit = 50): Collection
|
||||
{
|
||||
return ReanimationPatientIndicator::query()
|
||||
->where('rf_department_id', $departmentId)
|
||||
->where('rf_medicalhistory_id', $medicalHistoryId)
|
||||
->orderByDesc('reanimation_patient_indicator_id')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'reanimation_patient_indicator_id',
|
||||
'rf_report_id',
|
||||
'rf_medicalhistory_id',
|
||||
'indicator',
|
||||
'comment',
|
||||
'created_by',
|
||||
'created_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Services\UnifiedPatientService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Search-фасад для клинических справочных выборок отчётного интерфейса.
|
||||
*/
|
||||
class ReportClinicalSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UnifiedPatientService $unifiedPatientService,
|
||||
) {}
|
||||
|
||||
public function searchMisPatientsForDepartment(Department $department, string $query): Collection
|
||||
{
|
||||
return $this->unifiedPatientService->searchMisPatients($department, $query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Domain\Reports\ValueObjects\MetrikaConfig;
|
||||
use App\Models\Department;
|
||||
use App\Models\MisLpuDoctor;
|
||||
use App\Models\Report;
|
||||
use App\Models\UnwantedEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\DateRange;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Read-side сервис для metadata отчёта: текущий отчёт, события, планы и периоды.
|
||||
*/
|
||||
class ReportMetadataReadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReportReadContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
public function getCurrentReportInfo(Department $department, User $user, DateRange $dateRange): array
|
||||
{
|
||||
$reportToday = $this->contextResolver->resolveReportForPeriod($department->department_id, $dateRange);
|
||||
|
||||
$isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin();
|
||||
$useSnapshots = $isHeadOrAdmin || ! $dateRange->isEndDateToday() || $reportToday;
|
||||
|
||||
if ($useSnapshots && $isHeadOrAdmin && $reportToday) {
|
||||
$fillableUserId = $reportToday->rf_lpudoctor_id ?? null;
|
||||
} else {
|
||||
$fillableUserId = request()->query('userId', $user->rf_lpudoctor_id);
|
||||
}
|
||||
|
||||
$unwantedEvents = $this->getUnwantedEvents($department, $dateRange);
|
||||
$isActiveSendButton = $this->isSendButtonActive($user, $dateRange, $reportToday);
|
||||
|
||||
$message = null;
|
||||
if ($reportToday) {
|
||||
$reportDoctor = $reportToday->lpuDoctor;
|
||||
$message = "Отчет создан пользователем: $reportDoctor->FAM_V $reportDoctor->IM_V $reportDoctor->OT_V";
|
||||
}
|
||||
|
||||
$statusMessage = $reportToday
|
||||
? ($reportToday->status === 'submitted'
|
||||
? 'Этот отчет в статусе: опубликован'
|
||||
: 'Этот отчет в статусе: черновик')
|
||||
: null;
|
||||
|
||||
$lpuDoctor = $this->getDoctorInfo($fillableUserId, $dateRange);
|
||||
$date = $isHeadOrAdmin ? [
|
||||
$dateRange->startDate->getTimestampMs(),
|
||||
$dateRange->endDate->getTimestampMs(),
|
||||
] : $dateRange->endDate->getTimestampMs();
|
||||
|
||||
return [
|
||||
'report_id' => $reportToday?->report_id,
|
||||
'unwantedEvents' => $unwantedEvents,
|
||||
'isActiveSendButton' => $isActiveSendButton,
|
||||
'message' => $dateRange->isOneDay ? $message : null,
|
||||
'status' => $reportToday?->status ?? 'draft',
|
||||
'statusMessage' => $dateRange->isOneDay ? $statusMessage : null,
|
||||
'canPublish' => (bool) $reportToday && ($reportToday->status === 'draft') && $isActiveSendButton,
|
||||
'isOneDay' => $dateRange->isOneDay,
|
||||
'isHeadOrAdmin' => $isHeadOrAdmin,
|
||||
'dates' => $date,
|
||||
'userId' => $fillableUserId,
|
||||
'userName' => $lpuDoctor ? "$lpuDoctor->FAM_V $lpuDoctor->IM_V $lpuDoctor->OT_V" : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function getUnwantedEvents(Department $department, DateRange $dateRange): Collection
|
||||
{
|
||||
return UnwantedEvent::query()
|
||||
->whereHas('report', function ($query) use ($department, $dateRange) {
|
||||
$query->where('rf_department_id', $department->department_id);
|
||||
|
||||
if ($dateRange->isOneDay) {
|
||||
$query->exactPeriod($dateRange->startSql(), $dateRange->endSql());
|
||||
} else {
|
||||
$query->withinPeriod($dateRange->startSql(), $dateRange->endSql());
|
||||
}
|
||||
})
|
||||
->get()
|
||||
->map(function (UnwantedEvent $item) {
|
||||
return [
|
||||
...$item->toArray(),
|
||||
'created_at' => Carbon::parse($item->created_at)->format('Создано d.m.Y в H:i'),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Report>
|
||||
*/
|
||||
public function getReportsForDateRange(int $departmentId, DateRange $dateRange): Collection
|
||||
{
|
||||
return $this->contextResolver->getReportsForDateRange($departmentId, $dateRange);
|
||||
}
|
||||
|
||||
public function getRecipientPlanOfYear(Department $department, DateRange $dateRange): array
|
||||
{
|
||||
$periodPlanModel = $department->recipientPlanOfYear();
|
||||
$monthsInPeriod = ceil($dateRange->startDate->diffInMonths($dateRange->endDate));
|
||||
$annualPlan = $periodPlanModel ? (int) $periodPlanModel->value : 0;
|
||||
$oneMonthPlan = ceil($annualPlan / 12);
|
||||
$periodPlan = round($oneMonthPlan * $monthsInPeriod);
|
||||
|
||||
$query = $department->reports()
|
||||
->with('metrikaResults')
|
||||
->where('period_start', '>', $dateRange->startSql())
|
||||
->where('period_end', '<=', $dateRange->endSql());
|
||||
|
||||
if ($dateRange->isOneDay) {
|
||||
$query->where('period_start', '>=', $dateRange->startFirstOfMonth())
|
||||
->where('period_end', '<=', $dateRange->endSql());
|
||||
} else {
|
||||
$query->where('period_start', '>', $dateRange->startSql())
|
||||
->where('period_end', '<=', $dateRange->endSql());
|
||||
}
|
||||
|
||||
$progress = 0;
|
||||
|
||||
foreach ($query->get() as $report) {
|
||||
$outcome = $report->metrikaResults()
|
||||
->where('rf_metrika_item_id', MetrikaConfig::OUTCOME)
|
||||
->first();
|
||||
|
||||
if ($outcome) {
|
||||
$progress += (int) $outcome->value;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'plan' => $periodPlan,
|
||||
'progress' => $progress,
|
||||
];
|
||||
}
|
||||
|
||||
private function isSendButtonActive(User $user, DateRange $dateRange, ?Report $reportToday): bool
|
||||
{
|
||||
if (! $user->isHeadOfDepartment() && ! $user->isAdmin()) {
|
||||
if ($reportToday && $reportToday->status === 'submitted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $dateRange->isEndDateToday();
|
||||
}
|
||||
|
||||
return (bool) $reportToday && $dateRange->isOneDay;
|
||||
}
|
||||
|
||||
private function getDoctorInfo(?int $doctorId, DateRange $dateRange): ?MisLpuDoctor
|
||||
{
|
||||
if (! $doctorId || ! $dateRange->isOneDay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MisLpuDoctor::query()
|
||||
->where('LPUDoctorID', $doctorId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Domain\Reports\Models\StayInterval;
|
||||
use App\Domain\Reports\ValueObjects\MetrikaConfig;
|
||||
use App\Models\MedicalHistorySnapshot;
|
||||
use App\Models\Report;
|
||||
use DateTimeInterface;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -82,12 +83,12 @@ class ReportMetricsFinalizer
|
||||
$endRaw = null;
|
||||
|
||||
if ($snapshot->patient_type === 'deceased') {
|
||||
if ($history->DateDeath && ! in_array($history->DateDeath->format('Y-m-d'), ['1900-01-01', '2222-01-01'], true)) {
|
||||
if ($this->isRealDate($history->DateDeath)) {
|
||||
$endRaw = $history->DateDeath;
|
||||
} elseif ($history->DateExtract && ! in_array($history->DateExtract->format('Y-m-d'), ['1900-01-01', '2222-01-01'], true)) {
|
||||
} elseif ($this->isRealDate($history->DateExtract)) {
|
||||
$endRaw = $history->DateExtract;
|
||||
}
|
||||
} elseif ($history->DateExtract && ! in_array($history->DateExtract->format('Y-m-d'), ['1900-01-01', '2222-01-01'], true)) {
|
||||
} elseif ($this->isRealDate($history->DateExtract)) {
|
||||
$endRaw = $history->DateExtract;
|
||||
}
|
||||
|
||||
@@ -104,6 +105,19 @@ class ReportMetricsFinalizer
|
||||
return $intervals;
|
||||
}
|
||||
|
||||
private function isRealDate(mixed $value): bool
|
||||
{
|
||||
if (! $value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$date = $value instanceof DateTimeInterface
|
||||
? $value->format('Y-m-d')
|
||||
: (new DateTimeImmutable((string) $value))->format('Y-m-d');
|
||||
|
||||
return ! in_array($date, ['1900-01-01', '2222-01-01'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, OperationInterval>
|
||||
*/
|
||||
|
||||
@@ -235,6 +235,7 @@ class ReportPatientsReadService
|
||||
$patients = $this->snapshotService->getPatientsFromSnapshots(
|
||||
$patientType,
|
||||
$reportIds,
|
||||
$branchId,
|
||||
false,
|
||||
in_array($baseStatus, ['plan', 'emergency'], true),
|
||||
$recipientReportIds
|
||||
|
||||
@@ -101,6 +101,14 @@ class ReportReadContextResolver
|
||||
* Найти отчёт, который определяет видимость снапшотов для запрошенного периода.
|
||||
*/
|
||||
private function getReportForPeriod(int $departmentId, DateRange $dateRange): ?Report
|
||||
{
|
||||
return $this->resolveReportForPeriod($departmentId, $dateRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти отчёт для точного периода с теми же правилами, что использует legacy read-side.
|
||||
*/
|
||||
public function resolveReportForPeriod(int $departmentId, DateRange $dateRange): ?Report
|
||||
{
|
||||
$query = Report::query()
|
||||
->where('rf_department_id', $departmentId)
|
||||
|
||||
64
app/Infrastructure/Reports/Services/ReportRuntimeService.php
Normal file
64
app/Infrastructure/Reports/Services/ReportRuntimeService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Models\MisMedicalHistory;
|
||||
use App\Models\MisMigrationPatient;
|
||||
use App\Models\MisStationarBranch;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Runtime-обвязка тяжёлых report-save операций: память, query log и cache.
|
||||
*/
|
||||
class ReportRuntimeService
|
||||
{
|
||||
public function prepareForHeavySave(): void
|
||||
{
|
||||
$connectionNames = array_unique(array_filter([
|
||||
DB::getDefaultConnection(),
|
||||
(new MisMedicalHistory)->getConnectionName(),
|
||||
(new MisMigrationPatient)->getConnectionName(),
|
||||
(new MisStationarBranch)->getConnectionName(),
|
||||
]));
|
||||
|
||||
foreach ($connectionNames as $connectionName) {
|
||||
try {
|
||||
$connection = DB::connection($connectionName);
|
||||
$connection->disableQueryLog();
|
||||
$connection->flushQueryLog();
|
||||
} catch (\Throwable) {
|
||||
// best-effort cleanup only
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('gc_collect_cycles')) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCacheAfterReportCreation(User $user, Report $report): void
|
||||
{
|
||||
$this->clearDailyCache($user, $report->created_at);
|
||||
}
|
||||
|
||||
private function clearDailyCache(User $user, mixed $reportDate): void
|
||||
{
|
||||
$datesToClear = [
|
||||
Carbon::parse($reportDate)->format('Y-m-d'),
|
||||
Carbon::parse($reportDate)->subDay()->format('Y-m-d'),
|
||||
];
|
||||
|
||||
foreach ($datesToClear as $date) {
|
||||
Cache::forget($this->generateDailyCacheKey($user, $date));
|
||||
}
|
||||
}
|
||||
|
||||
private function generateDailyCacheKey(User $user, string $date): string
|
||||
{
|
||||
return 'daily_stats:'.$user->rf_department_id.':'.$date;
|
||||
}
|
||||
}
|
||||
164
app/Infrastructure/Reports/Services/ReportSaveOrchestrator.php
Normal file
164
app/Infrastructure/Reports/Services/ReportSaveOrchestrator.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Domain\Reports\Models\ReportSnapshot;
|
||||
use App\Domain\Reports\ValueObjects\MetrikaConfig;
|
||||
use App\Models\MedicalHistorySnapshot;
|
||||
use App\Models\MetrikaResult;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use App\Services\DateRangeService;
|
||||
use App\Services\SnapshotService;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Legacy-compatible save orchestration for report persistence.
|
||||
*
|
||||
* New architecture uses EloquentReportRepository directly; this service keeps
|
||||
* the old ReportService public API thin while the cutover is still gradual.
|
||||
*/
|
||||
class ReportSaveOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DateRangeService $dateRangeService,
|
||||
private readonly SnapshotService $snapshotService,
|
||||
private readonly ReportStorageService $reportStorageService,
|
||||
private readonly CalculatedMetricsSynchronizer $calculatedMetricsSynchronizer,
|
||||
private readonly ReportMetricsFinalizer $reportMetricsFinalizer,
|
||||
private readonly ReportRuntimeService $reportRuntimeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function storeReport(array $data, User $user, bool $fillableAuto = false): Report
|
||||
{
|
||||
$this->reportRuntimeService->prepareForHeavySave();
|
||||
$snapshot = $this->buildSnapshot($data, $user, $fillableAuto);
|
||||
|
||||
$report = DB::transaction(function () use ($snapshot, $user, $data, $fillableAuto) {
|
||||
$report = $this->reportStorageService->createOrUpdateReport($snapshot, $user);
|
||||
$this->reportStorageService->saveMetrics($report, $snapshot);
|
||||
$this->reportStorageService->saveUnwantedEvents($report, $snapshot);
|
||||
$this->reportStorageService->saveObservationPatients($report, $snapshot);
|
||||
|
||||
$this->snapshotService->createPatientSnapshots(
|
||||
$report,
|
||||
$user,
|
||||
[
|
||||
$snapshot->periodStart->getTimestamp(),
|
||||
$snapshot->periodEnd->getTimestamp(),
|
||||
],
|
||||
$fillableAuto
|
||||
);
|
||||
|
||||
$this->syncCalculatedMetrics($report, $user, $data);
|
||||
|
||||
return $report;
|
||||
});
|
||||
|
||||
DB::transaction(function () use ($report) {
|
||||
$this->finalizeStoredReport($report);
|
||||
$this->saveLethalMetricFromSnapshots($report);
|
||||
});
|
||||
|
||||
$this->reportRuntimeService->clearCacheAfterReportCreation($user, $report);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
public function syncCalculatedMetrics(Report $report, User $user, array $data): void
|
||||
{
|
||||
$this->calculatedMetricsSynchronizer->sync($report, $user, $data);
|
||||
}
|
||||
|
||||
public function finalizeStoredReport(Report $report): void
|
||||
{
|
||||
$this->reportMetricsFinalizer->finalize($report);
|
||||
}
|
||||
|
||||
public function saveLethalMetricFromSnapshots(Report $report): void
|
||||
{
|
||||
$snapshots = MedicalHistorySnapshot::query()
|
||||
->where('rf_report_id', $report->report_id)
|
||||
->whereIn('patient_type', ['discharged', 'deceased'])
|
||||
->with('medicalHistory')
|
||||
->get();
|
||||
|
||||
if ($snapshots->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
MetrikaResult::query()->updateOrCreate(
|
||||
[
|
||||
'rf_report_id' => $report->report_id,
|
||||
'rf_metrika_item_id' => MetrikaConfig::AVERAGE_BED_DAYS,
|
||||
],
|
||||
['value' => 0]
|
||||
);
|
||||
|
||||
\Log::info("No discharged patients in report {$report->report_id}, saved 0");
|
||||
}
|
||||
|
||||
private function buildSnapshot(array $data, User $user, bool $fillableAuto): ReportSnapshot
|
||||
{
|
||||
$dateRange = $this->dateRangeService->getNormalizedDateRange(
|
||||
$user,
|
||||
(string) ($data['dates'][0] ?? null),
|
||||
(string) ($data['dates'][1] ?? null)
|
||||
);
|
||||
|
||||
$rangeEndAt = $dateRange->endSql();
|
||||
$createdAt = $data['created_at'] ?? $rangeEndAt;
|
||||
$sentAt = $data['sent_at'] ?? $rangeEndAt;
|
||||
|
||||
return new ReportSnapshot(
|
||||
departmentId: (int) $data['departmentId'],
|
||||
userId: (int) $data['userId'],
|
||||
actorUserId: (int) $user->id,
|
||||
periodStart: new DateTimeImmutable($dateRange->startSql()),
|
||||
periodEnd: new DateTimeImmutable($dateRange->endSql()),
|
||||
status: (string) ($data['status'] ?? 'draft'),
|
||||
autoFill: $fillableAuto,
|
||||
metrics: MetrikaConfig::normalizeMetrics((array) ($data['metrics'] ?? [])),
|
||||
observationPatients: $this->normalizeObservationPatients((array) ($data['observationPatients'] ?? [])),
|
||||
unwantedEvents: $this->normalizeUnwantedEvents((array) ($data['unwantedEvents'] ?? [])),
|
||||
reportId: isset($data['reportId']) && $data['reportId'] ? (int) $data['reportId'] : null,
|
||||
createdAt: new DateTimeImmutable((string) $createdAt),
|
||||
sentAt: new DateTimeImmutable((string) $sentAt),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $patients
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizeObservationPatients(array $patients): array
|
||||
{
|
||||
return array_values(array_map(static function (array $patient): array {
|
||||
return [
|
||||
'medical_history_id' => $patient['medical_history_id'] ?? $patient['id'] ?? null,
|
||||
'department_patient_id' => $patient['department_patient_id'] ?? null,
|
||||
'comment' => $patient['comment'] ?? null,
|
||||
];
|
||||
}, $patients));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $events
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizeUnwantedEvents(array $events): array
|
||||
{
|
||||
return array_values(array_map(static function (array $event): array {
|
||||
return [
|
||||
'unwanted_event_id' => $event['unwanted_event_id'] ?? null,
|
||||
'title' => $event['title'] ?? '',
|
||||
'comment' => $event['comment'] ?? '',
|
||||
'is_visible' => (bool) ($event['is_visible'] ?? true),
|
||||
];
|
||||
}, $events));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure\Reports\Services;
|
||||
|
||||
use App\Domain\Reports\ValueObjects\MetrikaConfig;
|
||||
use App\Models\Department;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use App\Services\DateRange;
|
||||
use App\Services\PatientService;
|
||||
use App\Services\SnapshotService;
|
||||
use App\Services\UnifiedPatientService;
|
||||
|
||||
/**
|
||||
* Сервис чтения сводной статистики отчёта.
|
||||
*
|
||||
* Инкапсулирует выбор источника данных: submitted-снапшоты для закрытых
|
||||
* отчётов или live-реплика для текущей рабочей формы.
|
||||
*/
|
||||
class ReportStatisticsReadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UnifiedPatientService $unifiedPatientService,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly SnapshotService $snapshotService,
|
||||
private readonly ReportReadContextResolver $contextResolver,
|
||||
private readonly CalculatedMetricsSynchronizer $calculatedMetricsSynchronizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Получить статистику для шапки отчёта.
|
||||
*/
|
||||
public function getReportStatistics(Department $department, User $user, DateRange $dateRange): array
|
||||
{
|
||||
$branchId = $this->contextResolver->resolveBranchId($department);
|
||||
|
||||
if (! $branchId) {
|
||||
return $this->emptyStatistics();
|
||||
}
|
||||
|
||||
if ($this->contextResolver->shouldUseSnapshots($department, $dateRange)) {
|
||||
return $this->getStatisticsFromSnapshots($department, $dateRange);
|
||||
}
|
||||
|
||||
return $this->getStatisticsFromReplica($department, $user, $dateRange, $branchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику из сохранённых снапшотов submitted-отчётов.
|
||||
*/
|
||||
private function getStatisticsFromSnapshots(Department $department, DateRange $dateRange): array
|
||||
{
|
||||
$reports = $this->contextResolver->getReportsForDateRange(
|
||||
$department->department_id,
|
||||
$dateRange
|
||||
);
|
||||
|
||||
$reportIds = $reports->pluck('report_id')->all();
|
||||
$lastReportId = $reportIds[0] ?? null;
|
||||
$recipientReportIds = $this->contextResolver->getRecipientReportIds($reportIds);
|
||||
|
||||
$snapshotStats = [
|
||||
'plan' => $this->getMetrikaResultCount(MetrikaConfig::PLAN, $reportIds),
|
||||
'emergency' => $this->getMetrikaResultCount(MetrikaConfig::EMERGENCY, $reportIds),
|
||||
'outcome' => $this->getMetrikaResultCount(MetrikaConfig::OUTCOME, $reportIds),
|
||||
'deceased' => $this->getMetrikaResultCount(MetrikaConfig::DECEASED, $reportIds),
|
||||
'current' => $this->getMetrikaResultCount(MetrikaConfig::CURRENT, $reportIds, false),
|
||||
'transferred' => $this->getMetrikaResultCount(MetrikaConfig::TRANSFERRED, $reportIds),
|
||||
'recipient' => $this->getMetrikaResultCount(MetrikaConfig::RECIPIENT, $reportIds),
|
||||
'beds' => $this->getMetrikaResultCount(MetrikaConfig::BEDS, $reportIds, false),
|
||||
'countStaff' => $lastReportId
|
||||
? $this->getMetrikaResultCount(MetrikaConfig::STAFF_COUNT, [$lastReportId], false)
|
||||
: 0,
|
||||
];
|
||||
|
||||
$recipientIds = $this->snapshotService
|
||||
->getPatientsFromSnapshots('recipient', $recipientReportIds)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$surgicalCount = [
|
||||
$this->getMetrikaResultCount(MetrikaConfig::EMERGENCY_SURGERY, $reportIds),
|
||||
$this->getMetrikaResultCount(MetrikaConfig::PLAN_SURGERY, $reportIds),
|
||||
];
|
||||
|
||||
return [
|
||||
'recipientCount' => $snapshotStats['recipient'] ?? 0,
|
||||
'extractCount' => $snapshotStats['outcome'] ?? 0,
|
||||
'currentCount' => $snapshotStats['current'] ?? 0,
|
||||
'deadCount' => $snapshotStats['deceased'] ?? 0,
|
||||
'countStaff' => $snapshotStats['countStaff'] ?? 0,
|
||||
'surgicalCount' => $surgicalCount,
|
||||
'recipientIds' => $recipientIds,
|
||||
'beds' => $snapshotStats['beds'] ?? 0,
|
||||
'percentDead' => $this->calculatePercentDead($snapshotStats['deceased'], $snapshotStats['outcome']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику из live-реплики МИС и manual-источников.
|
||||
*/
|
||||
private function getStatisticsFromReplica(
|
||||
Department $department,
|
||||
User $user,
|
||||
DateRange $dateRange,
|
||||
int $branchId
|
||||
): array {
|
||||
$planCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'plan', $dateRange, $branchId, true);
|
||||
$emergencyCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'emergency', $dateRange, $branchId, true);
|
||||
$currentCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'current', $dateRange, $branchId);
|
||||
$recipientCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'recipient', $dateRange, $branchId);
|
||||
$outcomeCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'outcome', $dateRange, $branchId);
|
||||
$deadCount = $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'outcome-deceased', $dateRange, $branchId);
|
||||
|
||||
$misSurgicalCount = [
|
||||
$this->patientService->getSurgicalPatients('emergency', $branchId, $dateRange, true),
|
||||
$this->patientService->getSurgicalPatients('plan', $branchId, $dateRange, true),
|
||||
];
|
||||
$manualSurgicalCount = $this->calculatedMetricsSynchronizer->getManualSurgicalCounts($department, $dateRange);
|
||||
$surgicalCount = [
|
||||
($misSurgicalCount[0] ?? 0) + ($manualSurgicalCount[0] ?? 0),
|
||||
($misSurgicalCount[1] ?? 0) + ($manualSurgicalCount[1] ?? 0),
|
||||
];
|
||||
|
||||
$recipientIds = $this->unifiedPatientService
|
||||
->getRecipientIdsForReport($department, $user, $dateRange, $branchId);
|
||||
|
||||
return [
|
||||
'recipientCount' => $recipientCount,
|
||||
'extractCount' => $outcomeCount,
|
||||
'currentCount' => $currentCount,
|
||||
'deadCount' => $deadCount,
|
||||
'surgicalCount' => $surgicalCount,
|
||||
'recipientIds' => $recipientIds,
|
||||
'planCount' => $planCount,
|
||||
'emergencyCount' => $emergencyCount,
|
||||
'percentDead' => $this->calculatePercentDead($deadCount, $outcomeCount),
|
||||
'beds' => (int) ($department->metrikaDefault
|
||||
->where('rf_metrika_item_id', MetrikaConfig::BEDS)
|
||||
->first()
|
||||
?->value ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить агрегированное значение метрики из набора отчётов.
|
||||
*
|
||||
* @param array<int, int> $reportIds
|
||||
*/
|
||||
private function getMetrikaResultCount(int $metrikaItemId, array $reportIds, bool $sum = true): int
|
||||
{
|
||||
if (empty($reportIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$reports = Report::query()
|
||||
->whereIn('report_id', $reportIds)
|
||||
->with('metrikaResults')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
if (! $sum) {
|
||||
foreach ($reports as $report) {
|
||||
$metric = $report->metrikaResults
|
||||
->firstWhere('rf_metrika_item_id', $metrikaItemId);
|
||||
|
||||
if ($metric) {
|
||||
return (int) $metric->value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($reports as $report) {
|
||||
foreach ($report->metrikaResults as $metrikaResult) {
|
||||
if ((int) $metrikaResult->rf_metrika_item_id === $metrikaItemId) {
|
||||
$count += (int) $metrikaResult->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function calculatePercentDead(int $deadCount, int $outcomeCount): float|int
|
||||
{
|
||||
if ($outcomeCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($deadCount / $outcomeCount) * 100, 2);
|
||||
}
|
||||
|
||||
private function emptyStatistics(): array
|
||||
{
|
||||
return [
|
||||
'recipientCount' => 0,
|
||||
'extractCount' => 0,
|
||||
'currentCount' => 0,
|
||||
'deadCount' => 0,
|
||||
'surgicalCount' => [0, 0],
|
||||
'recipientIds' => [],
|
||||
'planCount' => 0,
|
||||
'emergencyCount' => 0,
|
||||
'percentDead' => 0,
|
||||
'beds' => 0,
|
||||
'countStaff' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user