From f107ebd167e65f279d068a509435bd1f1a0767a4 Mon Sep 17 00:00:00 2001 From: brusnitsyn Date: Sun, 26 Apr 2026 23:37:50 +0900 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD=D1=83=D1=8E?= =?UTF-8?q?=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + .../CompareLegacyAndNewReportUseCase.php | 72 ++ .../Reports/DTO/GenerateReportInput.php | 117 +++ .../Reports/DTO/GenerateReportResult.php | 13 + .../Reports/DTO/ReportComparisonResult.php | 22 + .../Reports/GenerateReportUseCase.php | 143 +++ app/Application/Reports/ReportFlowDecider.php | 13 + .../Reports/ReportInputFactory.php | 76 ++ .../Reports/ReportSavePathService.php | 56 ++ .../Commands/FillAverageBedDaysMetric.php | 11 +- .../RecalculatePreoperativeMetric.php | 13 +- .../Reports/Calculators/BedDaysCalculator.php | 33 + .../Calculators/DepartmentLoadCalculator.php | 15 + .../PreoperativeDaysCalculator.php | 33 + app/Domain/Reports/Contracts/AuditLogger.php | 10 + .../Reports/Contracts/MetricCalculator.php | 12 + .../Reports/Contracts/PatientSource.php | 11 + .../Reports/Contracts/ReportRepository.php | 13 + app/Domain/Reports/Models/MetricAggregate.php | 12 + .../Reports/Models/MetricResultCollection.php | 23 + .../Reports/Models/OperationInterval.php | 13 + .../Reports/Models/PatientCollection.php | 20 + app/Domain/Reports/Models/ReportContext.php | 21 + app/Domain/Reports/Models/ReportSnapshot.php | 85 ++ .../Reports/Models/SavedReportResult.php | 11 + app/Domain/Reports/Models/StayInterval.php | 13 + .../Reports/ValueObjects/MetrikaConfig.php | 100 +++ app/Http/Controllers/Api/ReportController.php | 396 +-------- app/Http/Controllers/Web/ReportController.php | 6 +- .../Adapters/LegacyReportServiceAdapter.php | 146 ++++ .../Reports/Logging/ReportsAuditLogger.php | 26 + .../Repositories/EloquentReportRepository.php | 113 +++ .../Services/AutoFillReportPayloadBuilder.php | 175 ++++ .../CalculatedMetricsSynchronizer.php | 141 +++ .../Services/ReportMetricsFinalizer.php | 154 ++++ .../Services/ReportPatientsReadService.php | 398 +++++++++ .../Services/ReportReadContextResolver.php | 116 +++ .../Reports/Services/ReportStorageService.php | 166 ++++ .../Services/SnapshotPersistenceService.php | 80 ++ .../Sources/LegacyAutoFillPatientSource.php | 42 + .../Reports/Sources/MisClinicalDataSource.php | 323 +++++++ .../Reports/Sources/MisPatientSource.php | 144 +++ .../Reports/Sources/SnapshotPatientSource.php | 218 +++++ .../Reports/Sources/SpecialPatientSource.php | 207 +++++ app/Models/Report.php | 3 +- app/Providers/AppServiceProvider.php | 30 + app/Services/AutoReportService.php | 20 +- app/Services/PatientService.php | 395 +-------- app/Services/ReportService.php | 827 ++---------------- app/Services/Reports/PatientQueryBuilder.php | 110 +++ app/Services/SnapshotService.php | 292 +------ app/Services/UnifiedPatientService.php | 316 +------ config/excel.php | 34 +- config/logging.php | 8 + config/reports.php | 10 + docs/adr/0001-report-domain-strangler.md | 19 + docs/report-domain-migration-handoff.md | 170 ++++ package-lock.json | 6 + tests/Feature/AutoFillReportsTest.php | 13 +- .../Reports/EloquentReportRepositoryTest.php | 150 ++++ tests/Pest.php | 2 +- tests/Unit/Reports/BedDaysCalculatorTest.php | 38 + .../CompareLegacyAndNewReportUseCaseTest.php | 47 + .../Reports/DepartmentLoadCalculatorTest.php | 15 + .../Reports/GenerateReportUseCaseTest.php | 92 ++ tests/Unit/Reports/MetrikaConfigTest.php | 28 + .../PreoperativeDaysCalculatorTest.php | 38 + tests/Unit/Reports/ReportInputFactoryTest.php | 58 ++ .../Reports/ReportPatientsReadServiceTest.php | 148 ++++ tests/Unit/Reports/ReportSnapshotTest.php | 36 + 70 files changed, 4656 insertions(+), 2070 deletions(-) create mode 100644 app/Application/Reports/CompareLegacyAndNewReportUseCase.php create mode 100644 app/Application/Reports/DTO/GenerateReportInput.php create mode 100644 app/Application/Reports/DTO/GenerateReportResult.php create mode 100644 app/Application/Reports/DTO/ReportComparisonResult.php create mode 100644 app/Application/Reports/GenerateReportUseCase.php create mode 100644 app/Application/Reports/ReportFlowDecider.php create mode 100644 app/Application/Reports/ReportInputFactory.php create mode 100644 app/Application/Reports/ReportSavePathService.php create mode 100644 app/Domain/Reports/Calculators/BedDaysCalculator.php create mode 100644 app/Domain/Reports/Calculators/DepartmentLoadCalculator.php create mode 100644 app/Domain/Reports/Calculators/PreoperativeDaysCalculator.php create mode 100644 app/Domain/Reports/Contracts/AuditLogger.php create mode 100644 app/Domain/Reports/Contracts/MetricCalculator.php create mode 100644 app/Domain/Reports/Contracts/PatientSource.php create mode 100644 app/Domain/Reports/Contracts/ReportRepository.php create mode 100644 app/Domain/Reports/Models/MetricAggregate.php create mode 100644 app/Domain/Reports/Models/MetricResultCollection.php create mode 100644 app/Domain/Reports/Models/OperationInterval.php create mode 100644 app/Domain/Reports/Models/PatientCollection.php create mode 100644 app/Domain/Reports/Models/ReportContext.php create mode 100644 app/Domain/Reports/Models/ReportSnapshot.php create mode 100644 app/Domain/Reports/Models/SavedReportResult.php create mode 100644 app/Domain/Reports/Models/StayInterval.php create mode 100644 app/Domain/Reports/ValueObjects/MetrikaConfig.php create mode 100644 app/Infrastructure/Reports/Adapters/LegacyReportServiceAdapter.php create mode 100644 app/Infrastructure/Reports/Logging/ReportsAuditLogger.php create mode 100644 app/Infrastructure/Reports/Repositories/EloquentReportRepository.php create mode 100644 app/Infrastructure/Reports/Services/AutoFillReportPayloadBuilder.php create mode 100644 app/Infrastructure/Reports/Services/CalculatedMetricsSynchronizer.php create mode 100644 app/Infrastructure/Reports/Services/ReportMetricsFinalizer.php create mode 100644 app/Infrastructure/Reports/Services/ReportPatientsReadService.php create mode 100644 app/Infrastructure/Reports/Services/ReportReadContextResolver.php create mode 100644 app/Infrastructure/Reports/Services/ReportStorageService.php create mode 100644 app/Infrastructure/Reports/Services/SnapshotPersistenceService.php create mode 100644 app/Infrastructure/Reports/Sources/LegacyAutoFillPatientSource.php create mode 100644 app/Infrastructure/Reports/Sources/MisClinicalDataSource.php create mode 100644 app/Infrastructure/Reports/Sources/MisPatientSource.php create mode 100644 app/Infrastructure/Reports/Sources/SnapshotPatientSource.php create mode 100644 app/Infrastructure/Reports/Sources/SpecialPatientSource.php create mode 100644 config/reports.php create mode 100644 docs/adr/0001-report-domain-strangler.md create mode 100644 docs/report-domain-migration-handoff.md create mode 100644 tests/Feature/Reports/EloquentReportRepositoryTest.php create mode 100644 tests/Unit/Reports/BedDaysCalculatorTest.php create mode 100644 tests/Unit/Reports/CompareLegacyAndNewReportUseCaseTest.php create mode 100644 tests/Unit/Reports/DepartmentLoadCalculatorTest.php create mode 100644 tests/Unit/Reports/GenerateReportUseCaseTest.php create mode 100644 tests/Unit/Reports/MetrikaConfigTest.php create mode 100644 tests/Unit/Reports/PreoperativeDaysCalculatorTest.php create mode 100644 tests/Unit/Reports/ReportInputFactoryTest.php create mode 100644 tests/Unit/Reports/ReportPatientsReadServiceTest.php create mode 100644 tests/Unit/Reports/ReportSnapshotTest.php diff --git a/README.md b/README.md index 0165a77..c5b5d98 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ License

+## Report Domain Migration + +The first medical report now supports a strangler-style save path under `app/Domain/Reports`, `app/Application/Reports`, and `app/Infrastructure/Reports`. + +- Enable the new save flow per report type through `REPORTS_USE_NEW_ARCH_TYPES=daily`. +- Audit comparison results are written to the `reports_audit` log channel. +- New report logic should keep formulas in Domain classes and Laravel/Eloquent adapters in Infrastructure classes. +- See [docs/adr/0001-report-domain-strangler.md](docs/adr/0001-report-domain-strangler.md) for the architectural decision record. + ## About Laravel Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: diff --git a/app/Application/Reports/CompareLegacyAndNewReportUseCase.php b/app/Application/Reports/CompareLegacyAndNewReportUseCase.php new file mode 100644 index 0000000..419b25a --- /dev/null +++ b/app/Application/Reports/CompareLegacyAndNewReportUseCase.php @@ -0,0 +1,72 @@ +legacyAdapter->buildSnapshotFromInput($input); + + if ($input->persistedReportId === null) { + throw new RuntimeException('persistedReportId is required for report comparison.'); + } + + $actual = $this->reportRepository->findSnapshot($input->persistedReportId); + + if ($actual === null) { + throw new RuntimeException('Saved report snapshot was not found for comparison.'); + } + + $diff = $this->buildDiff($expected->toComparableArray(), $actual->toComparableArray()); + $status = empty($diff) ? 'matched' : 'diff'; + + return new ReportComparisonResult( + reportType: $input->reportType, + path: 'new', + status: $status, + diff: $diff, + departmentId: $input->departmentId, + userId: $input->userId, + periodStart: $input->periodStart->format('Y-m-d H:i:s'), + periodEnd: $input->periodEnd->format('Y-m-d H:i:s'), + reportId: $input->persistedReportId, + durationMs: round((microtime(true) - $startedAt) * 1000, 2), + ); + } + + /** + * @param array $expected + * @param array $actual + * @return array + */ + private function buildDiff(array $expected, array $actual): array + { + $diff = []; + + foreach ($expected as $key => $value) { + $actualValue = $actual[$key] ?? null; + + if ($actualValue !== $value) { + $diff[$key] = [ + 'expected' => $value, + 'actual' => $actualValue, + ]; + } + } + + return $diff; + } +} diff --git a/app/Application/Reports/DTO/GenerateReportInput.php b/app/Application/Reports/DTO/GenerateReportInput.php new file mode 100644 index 0000000..2ae9775 --- /dev/null +++ b/app/Application/Reports/DTO/GenerateReportInput.php @@ -0,0 +1,117 @@ + $metrics + * @param array> $observationPatients + * @param array> $unwantedEvents + * @param array|null $rawPayload + */ + public function __construct( + public int $departmentId, + public int $userId, + public ?int $actorUserId, + public DateTimeImmutable $periodStart, + public DateTimeImmutable $periodEnd, + public array $metrics = [], + public array $observationPatients = [], + public array $unwantedEvents = [], + public ?int $reportId = null, + public string $status = 'draft', + public string $reportType = 'daily', + public bool $autoFill = false, + public ?array $rawPayload = null, + public ?int $persistedReportId = null, + public ?DateTimeImmutable $createdAt = null, + public ?DateTimeImmutable $sentAt = null, + ) {} + + public function toContext(): ReportContext + { + return new ReportContext( + departmentId: $this->departmentId, + userId: $this->userId, + actorUserId: $this->actorUserId, + periodStart: $this->periodStart, + periodEnd: $this->periodEnd, + reportType: $this->reportType, + metadata: [ + 'auto_fill' => $this->autoFill, + ], + ); + } + + public function toSnapshot(): ReportSnapshot + { + return new ReportSnapshot( + departmentId: $this->departmentId, + userId: $this->userId, + actorUserId: $this->actorUserId, + periodStart: $this->periodStart, + periodEnd: $this->periodEnd, + status: $this->status, + autoFill: $this->autoFill, + metrics: $this->metrics, + observationPatients: $this->observationPatients, + unwantedEvents: $this->unwantedEvents, + reportId: $this->reportId, + createdAt: $this->createdAt, + sentAt: $this->sentAt, + reportType: $this->reportType, + ); + } + + /** + * @param array $payload + */ + public function withRawPayload(array $payload): self + { + return new self( + departmentId: $this->departmentId, + userId: $this->userId, + actorUserId: $this->actorUserId, + periodStart: $this->periodStart, + periodEnd: $this->periodEnd, + metrics: $this->metrics, + observationPatients: $this->observationPatients, + unwantedEvents: $this->unwantedEvents, + reportId: $this->reportId, + status: $this->status, + reportType: $this->reportType, + autoFill: $this->autoFill, + rawPayload: $payload, + persistedReportId: $this->persistedReportId, + createdAt: $this->createdAt, + sentAt: $this->sentAt, + ); + } + + public function withPersistedReportId(int $reportId): self + { + return new self( + departmentId: $this->departmentId, + userId: $this->userId, + actorUserId: $this->actorUserId, + periodStart: $this->periodStart, + periodEnd: $this->periodEnd, + metrics: $this->metrics, + observationPatients: $this->observationPatients, + unwantedEvents: $this->unwantedEvents, + reportId: $this->reportId, + status: $this->status, + reportType: $this->reportType, + autoFill: $this->autoFill, + rawPayload: $this->rawPayload, + persistedReportId: $reportId, + createdAt: $this->createdAt, + sentAt: $this->sentAt, + ); + } +} diff --git a/app/Application/Reports/DTO/GenerateReportResult.php b/app/Application/Reports/DTO/GenerateReportResult.php new file mode 100644 index 0000000..3859be5 --- /dev/null +++ b/app/Application/Reports/DTO/GenerateReportResult.php @@ -0,0 +1,13 @@ + $diff + */ + public function __construct( + public string $reportType, + public string $path, + public string $status, + public array $diff, + public int $departmentId, + public int $userId, + public string $periodStart, + public string $periodEnd, + public ?int $reportId, + public float $durationMs, + ) {} +} diff --git a/app/Application/Reports/GenerateReportUseCase.php b/app/Application/Reports/GenerateReportUseCase.php new file mode 100644 index 0000000..2b2c1b9 --- /dev/null +++ b/app/Application/Reports/GenerateReportUseCase.php @@ -0,0 +1,143 @@ + $calculators + */ + public function __construct( + private ReportRepository $reportRepository, + private AuditLogger $auditLogger, + private CompareLegacyAndNewReportUseCase $comparator, + private ?PatientSource $patientSource = null, + private iterable $calculators = [], + private bool $compareBeforeCutover = true, + ) {} + + public function handle(GenerateReportInput $input): GenerateReportResult + { + $resolvedInput = $this->resolveInput($input); + $saved = $this->reportRepository->save($resolvedInput->toSnapshot()); + + $comparison = null; + + if ($this->compareBeforeCutover) { + try { + $comparison = $this->comparator->handle( + $resolvedInput->withPersistedReportId($saved->reportId) + ); + } catch (\Throwable $exception) { + $comparison = new ReportComparisonResult( + reportType: $resolvedInput->reportType, + path: 'new', + status: 'failed', + diff: ['error' => $exception->getMessage()], + departmentId: $resolvedInput->departmentId, + userId: $resolvedInput->userId, + periodStart: $resolvedInput->periodStart->format('Y-m-d H:i:s'), + periodEnd: $resolvedInput->periodEnd->format('Y-m-d H:i:s'), + reportId: $saved->reportId, + durationMs: 0.0, + ); + } + + $this->auditLogger->logComparison($comparison); + } + + return new GenerateReportResult( + reportId: $saved->reportId, + path: 'new', + usedNewArchitecture: true, + comparison: $comparison, + ); + } + + private function resolveInput(GenerateReportInput $input): GenerateReportInput + { + if ($input->rawPayload !== null) { + return $input; + } + + if ($this->patientSource === null) { + return $input; + } + + $patients = $this->patientSource->load($input->toContext()); + $payload = $patients->metadata('payload', []); + + if (! is_array($payload) || $payload === []) { + $payload = $this->buildPayloadFromCalculators($input, $patients); + } + + return new GenerateReportInput( + departmentId: $input->departmentId, + userId: $input->userId, + actorUserId: $input->actorUserId, + periodStart: $input->periodStart, + periodEnd: $input->periodEnd, + metrics: (array) ($payload['metrics'] ?? $input->metrics), + observationPatients: (array) ($payload['observationPatients'] ?? $input->observationPatients), + unwantedEvents: (array) ($payload['unwantedEvents'] ?? $input->unwantedEvents), + reportId: isset($payload['reportId']) ? (int) $payload['reportId'] : $input->reportId, + status: (string) ($payload['status'] ?? $input->status), + reportType: $input->reportType, + autoFill: $input->autoFill, + rawPayload: $payload, + persistedReportId: $input->persistedReportId, + createdAt: isset($payload['created_at']) + ? new DateTimeImmutable((string) $payload['created_at']) + : $input->createdAt, + sentAt: isset($payload['sent_at']) + ? new DateTimeImmutable((string) $payload['sent_at']) + : $input->sentAt, + ); + } + + /** + * Переходный fallback для будущих сценариев с calculator-based flow. + * + * @return array + */ + private function buildPayloadFromCalculators(GenerateReportInput $input, PatientCollection $patients): array + { + $metrics = $input->metrics; + + foreach ($this->calculators as $calculator) { + $metrics = [ + ...$metrics, + ...$calculator->calculate($input->toContext(), $patients)->normalized(), + ]; + } + + return [ + 'departmentId' => $input->departmentId, + 'userId' => $input->userId, + 'actorUserId' => $input->actorUserId, + 'autoFill' => $input->autoFill, + 'dates' => [ + $input->periodStart->getTimestamp(), + $input->periodEnd->getTimestamp(), + ], + 'status' => $input->status, + 'created_at' => $input->createdAt?->format('Y-m-d H:i:s') ?? $input->periodEnd->format('Y-m-d H:i:s'), + 'sent_at' => $input->sentAt?->format('Y-m-d H:i:s') ?? $input->periodEnd->format('Y-m-d H:i:s'), + 'metrics' => MetrikaConfig::toPayloadMetrics($metrics), + 'observationPatients' => $input->observationPatients, + 'unwantedEvents' => $input->unwantedEvents, + 'reportId' => $input->reportId, + ]; + } +} diff --git a/app/Application/Reports/ReportFlowDecider.php b/app/Application/Reports/ReportFlowDecider.php new file mode 100644 index 0000000..3566f44 --- /dev/null +++ b/app/Application/Reports/ReportFlowDecider.php @@ -0,0 +1,13 @@ + $validated + */ + public function forManualSave(User $actor, array $validated, string $reportType = 'daily'): GenerateReportInput + { + $dateRange = $this->dateRangeService->getNormalizedDateRange( + $actor, + (string) ($validated['dates'][0] ?? null), + (string) ($validated['dates'][1] ?? null), + ); + + $endedAt = new DateTimeImmutable($dateRange->endSql()); + + return new GenerateReportInput( + departmentId: (int) $validated['departmentId'], + userId: (int) $validated['userId'], + actorUserId: (int) $actor->id, + periodStart: new DateTimeImmutable($dateRange->startSql()), + periodEnd: $endedAt, + metrics: (array) ($validated['metrics'] ?? []), + observationPatients: (array) ($validated['observationPatients'] ?? []), + unwantedEvents: (array) ($validated['unwantedEvents'] ?? []), + reportId: isset($validated['reportId']) ? (int) $validated['reportId'] : null, + status: (string) ($validated['status'] ?? 'draft'), + reportType: $reportType, + autoFill: false, + rawPayload: [ + ...$validated, + 'actorUserId' => $actor->id, + 'created_at' => $dateRange->endSql(), + 'sent_at' => $dateRange->endSql(), + ], + createdAt: $endedAt, + sentAt: $endedAt, + ); + } + + public function forAutoFill( + User $scopedUser, + Department $department, + DateRange $dateRange, + string $reportType = 'daily', + ): GenerateReportInput { + $endedAt = new DateTimeImmutable($dateRange->endSql()); + + return new GenerateReportInput( + departmentId: (int) $department->department_id, + userId: (int) ($scopedUser->rf_lpudoctor_id ?? $scopedUser->id), + actorUserId: (int) $scopedUser->id, + periodStart: new DateTimeImmutable($dateRange->startSql()), + periodEnd: $endedAt, + status: 'submitted', + reportType: $reportType, + autoFill: true, + createdAt: $endedAt, + sentAt: $endedAt, + ); + } +} diff --git a/app/Application/Reports/ReportSavePathService.php b/app/Application/Reports/ReportSavePathService.php new file mode 100644 index 0000000..e79d5eb --- /dev/null +++ b/app/Application/Reports/ReportSavePathService.php @@ -0,0 +1,56 @@ +reportFlowDecider->shouldUseNewArchitecture($reportType); + } + + /** + * @param array $validated + */ + public function saveManual(User $actor, array $validated, string $reportType = 'daily'): GenerateReportResult|Report + { + if (! $this->usesNewArchitecture($reportType)) { + return $this->reportService->storeReport($validated, $actor, false); + } + + return $this->generateReportUseCase->handle( + $this->reportInputFactory->forManualSave($actor, $validated, $reportType) + ); + } + + public function saveAutoFill( + User $scopedUser, + Department $department, + DateRange $dateRange, + string $reportType = 'daily', + ): GenerateReportResult|Report { + if (! $this->usesNewArchitecture($reportType)) { + $payload = $this->reportService->buildAutoFillReportPayload($scopedUser, $department, $dateRange); + + return $this->reportService->storeReport($payload, $scopedUser, true); + } + + return $this->generateReportUseCase->handle( + $this->reportInputFactory->forAutoFill($scopedUser, $department, $dateRange, $reportType) + ); + } +} diff --git a/app/Console/Commands/FillAverageBedDaysMetric.php b/app/Console/Commands/FillAverageBedDaysMetric.php index 65e05cb..f4975d8 100644 --- a/app/Console/Commands/FillAverageBedDaysMetric.php +++ b/app/Console/Commands/FillAverageBedDaysMetric.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; +use App\Domain\Reports\ValueObjects\MetrikaConfig; use App\Models\MedicalHistorySnapshot; use App\Models\MetrikaResult; use App\Models\Report; @@ -142,7 +143,7 @@ class FillAverageBedDaysMetric extends Command // Get a sample of recently updated reports $sampleQuery = Report::whereHas('metrikaResults', function ($q) { - $q->where('rf_metrika_item_id', 18); + $q->where('rf_metrika_item_id', MetrikaConfig::AVERAGE_BED_DAYS); }) ->orderBy('report_id', 'desc') ->limit(5); @@ -160,7 +161,7 @@ class FillAverageBedDaysMetric extends Command $sample = $sampleQuery->get()->map(function ($report) { $metric = $report->metrikaResults - ->where('rf_metrika_item_id', 18) + ->where('rf_metrika_item_id', MetrikaConfig::AVERAGE_BED_DAYS) ->first(); return [ @@ -189,7 +190,7 @@ class FillAverageBedDaysMetric extends Command { // Check if metric already exists $existingMetric = MetrikaResult::where('rf_report_id', $report->report_id) - ->where('rf_metrika_item_id', 18) + ->where('rf_metrika_item_id', MetrikaConfig::AVERAGE_BED_DAYS) ->first(); if ($existingMetric && ! $force) { @@ -206,7 +207,7 @@ class FillAverageBedDaysMetric extends Command MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 18, + 'rf_metrika_item_id' => MetrikaConfig::AVERAGE_BED_DAYS, ], ['value' => 0] ); @@ -221,7 +222,7 @@ class FillAverageBedDaysMetric extends Command MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 18, + 'rf_metrika_item_id' => MetrikaConfig::AVERAGE_BED_DAYS, ], ['value' => $avgBedDays] ); diff --git a/app/Console/Commands/RecalculatePreoperativeMetric.php b/app/Console/Commands/RecalculatePreoperativeMetric.php index 49396d8..e0d1928 100644 --- a/app/Console/Commands/RecalculatePreoperativeMetric.php +++ b/app/Console/Commands/RecalculatePreoperativeMetric.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; +use App\Domain\Reports\ValueObjects\MetrikaConfig; use App\Models\MedicalHistorySnapshot; use App\Models\MetrikaResult; use App\Models\Report; @@ -135,14 +136,14 @@ class RecalculatePreoperativeMetric extends Command $this->info('📋 Примеры обновленных отчетов:'); $samples = Report::whereHas('metrikaResults', function ($q) { - $q->where('rf_metrika_item_id', 21); + $q->where('rf_metrika_item_id', MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS); }) ->orderBy('report_id', 'desc') ->limit(5) ->get() ->map(function ($report) { $metric = $report->metrikaResults - ->where('rf_metrika_item_id', 21) + ->where('rf_metrika_item_id', MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS) ->first(); return [ @@ -169,7 +170,7 @@ class RecalculatePreoperativeMetric extends Command { // Проверяем, есть ли уже метрика $existing = MetrikaResult::where('rf_report_id', $report->report_id) - ->where('rf_metrika_item_id', 21) + ->where('rf_metrika_item_id', MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS) ->first(); if ($existing && ! $force) { @@ -186,7 +187,7 @@ class RecalculatePreoperativeMetric extends Command MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 21, + 'rf_metrika_item_id' => MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS, ], ['value' => 0] ); @@ -213,7 +214,7 @@ class RecalculatePreoperativeMetric extends Command MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 21, + 'rf_metrika_item_id' => MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS, ], ['value' => 0] ); @@ -240,7 +241,7 @@ class RecalculatePreoperativeMetric extends Command MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 21, + 'rf_metrika_item_id' => MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS, ], ['value' => $avgDays] ); diff --git a/app/Domain/Reports/Calculators/BedDaysCalculator.php b/app/Domain/Reports/Calculators/BedDaysCalculator.php new file mode 100644 index 0000000..4ded95b --- /dev/null +++ b/app/Domain/Reports/Calculators/BedDaysCalculator.php @@ -0,0 +1,33 @@ + $intervals + */ + public function calculate(iterable $intervals): MetricAggregate + { + $totalDays = 0; + $patientCount = 0; + + foreach ($intervals as $interval) { + if ($interval->endAt < $interval->startAt) { + continue; + } + + $totalDays += $interval->startAt->setTime(0, 0)->diff($interval->endAt->setTime(0, 0))->days; + $patientCount++; + } + + return new MetricAggregate( + total: $totalDays, + count: $patientCount, + average: $patientCount > 0 ? round($totalDays / $patientCount, 2) : 0.0, + ); + } +} diff --git a/app/Domain/Reports/Calculators/DepartmentLoadCalculator.php b/app/Domain/Reports/Calculators/DepartmentLoadCalculator.php new file mode 100644 index 0000000..ea1b710 --- /dev/null +++ b/app/Domain/Reports/Calculators/DepartmentLoadCalculator.php @@ -0,0 +1,15 @@ + $intervals + */ + public function calculate(iterable $intervals): MetricAggregate + { + $totalDays = 0; + $patientCount = 0; + + foreach ($intervals as $interval) { + if ($interval->operationAt < $interval->admittedAt) { + continue; + } + + $totalDays += $interval->admittedAt->setTime(0, 0)->diff($interval->operationAt->setTime(0, 0))->days; + $patientCount++; + } + + return new MetricAggregate( + total: $totalDays, + count: $patientCount, + average: $patientCount > 0 ? round($totalDays / $patientCount, 1) : 0.0, + ); + } +} diff --git a/app/Domain/Reports/Contracts/AuditLogger.php b/app/Domain/Reports/Contracts/AuditLogger.php new file mode 100644 index 0000000..35c2c0d --- /dev/null +++ b/app/Domain/Reports/Contracts/AuditLogger.php @@ -0,0 +1,10 @@ + $metrics + */ + public function __construct( + public array $metrics = [], + ) {} + + /** + * @return array + */ + public function normalized(): array + { + return MetrikaConfig::normalizeMetrics($this->metrics); + } +} diff --git a/app/Domain/Reports/Models/OperationInterval.php b/app/Domain/Reports/Models/OperationInterval.php new file mode 100644 index 0000000..e8f8d0f --- /dev/null +++ b/app/Domain/Reports/Models/OperationInterval.php @@ -0,0 +1,13 @@ +> $items + * @param array $metadata + */ + public function __construct( + public array $items = [], + public array $metadata = [], + ) {} + + public function metadata(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } +} diff --git a/app/Domain/Reports/Models/ReportContext.php b/app/Domain/Reports/Models/ReportContext.php new file mode 100644 index 0000000..c1f6a96 --- /dev/null +++ b/app/Domain/Reports/Models/ReportContext.php @@ -0,0 +1,21 @@ + $metadata + */ + public function __construct( + public int $departmentId, + public int $userId, + public ?int $actorUserId, + public DateTimeImmutable $periodStart, + public DateTimeImmutable $periodEnd, + public string $reportType = 'daily', + public array $metadata = [], + ) {} +} diff --git a/app/Domain/Reports/Models/ReportSnapshot.php b/app/Domain/Reports/Models/ReportSnapshot.php new file mode 100644 index 0000000..87e6a30 --- /dev/null +++ b/app/Domain/Reports/Models/ReportSnapshot.php @@ -0,0 +1,85 @@ + $metrics + * @param array> $observationPatients + * @param array> $unwantedEvents + */ + public function __construct( + public int $departmentId, + public int $userId, + public ?int $actorUserId, + public DateTimeImmutable $periodStart, + public DateTimeImmutable $periodEnd, + public string $status = 'draft', + public bool $autoFill = false, + public array $metrics = [], + public array $observationPatients = [], + public array $unwantedEvents = [], + public ?int $reportId = null, + public ?DateTimeImmutable $createdAt = null, + public ?DateTimeImmutable $sentAt = null, + public string $reportType = 'daily', + ) { + if ($this->departmentId <= 0) { + throw new InvalidArgumentException('departmentId must be positive.'); + } + + if ($this->userId <= 0) { + throw new InvalidArgumentException('userId must be positive.'); + } + + if ($this->periodEnd < $this->periodStart) { + throw new InvalidArgumentException('periodEnd must not be earlier than periodStart.'); + } + } + + /** + * @return array + */ + public function normalizedMetrics(): array + { + return MetrikaConfig::normalizeMetrics($this->metrics); + } + + /** + * @return array + */ + public function payloadMetrics(): array + { + return MetrikaConfig::toPayloadMetrics($this->normalizedMetrics()); + } + + /** + * @return array + */ + public function toComparableArray(): array + { + $observationPatients = $this->observationPatients; + $unwantedEvents = $this->unwantedEvents; + + sort($observationPatients); + sort($unwantedEvents); + + return [ + 'department_id' => $this->departmentId, + 'user_id' => $this->userId, + 'actor_user_id' => $this->actorUserId, + 'period_start' => $this->periodStart->format('Y-m-d H:i:s'), + 'period_end' => $this->periodEnd->format('Y-m-d H:i:s'), + 'status' => $this->status, + 'auto_fill' => $this->autoFill, + 'metrics' => $this->normalizedMetrics(), + 'observation_patients' => $observationPatients, + 'unwanted_events' => $unwantedEvents, + ]; + } +} diff --git a/app/Domain/Reports/Models/SavedReportResult.php b/app/Domain/Reports/Models/SavedReportResult.php new file mode 100644 index 0000000..701d465 --- /dev/null +++ b/app/Domain/Reports/Models/SavedReportResult.php @@ -0,0 +1,11 @@ + $metrics + * @return array + */ + public static function normalizeMetrics(array $metrics): array + { + $normalized = []; + + foreach ($metrics as $key => $value) { + $metricId = self::extractMetricId($key); + + if ($metricId === null) { + continue; + } + + $normalized[$metricId] = $value; + } + + ksort($normalized); + + return $normalized; + } + + /** + * @param array $metrics + * @return array + */ + public static function toPayloadMetrics(array $metrics): array + { + $payload = []; + + foreach (self::normalizeMetrics($metrics) as $metricId => $value) { + $payload[self::payloadKey($metricId)] = $value; + } + + return $payload; + } + + public static function extractMetricId(int|string $key): ?int + { + if (is_int($key) || ctype_digit((string) $key)) { + return (int) $key; + } + + if (preg_match('/^metrika_item_(\d+)$/', (string) $key, $matches) !== 1) { + return null; + } + + return (int) $matches[1]; + } +} diff --git a/app/Http/Controllers/Api/ReportController.php b/app/Http/Controllers/Api/ReportController.php index bd77241..c36f24c 100644 --- a/app/Http/Controllers/Api/ReportController.php +++ b/app/Http/Controllers/Api/ReportController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers\Api; +use App\Application\Reports\ReportSavePathService; +use App\Domain\Reports\ValueObjects\MetrikaConfig; use App\Http\Controllers\Controller; use App\Http\Resources\Api\DepartmentPatientOperationResource; use App\Http\Resources\Mis\FormattedPatientResource; @@ -33,7 +35,9 @@ class ReportController extends Controller public function __construct( protected MisPatientService $misPatientService, protected ReportService $reportService, - protected DateRangeService $dateRangeService) {} + protected DateRangeService $dateRangeService, + protected ReportSavePathService $reportSavePathService, + ) {} public function index(Request $request) { @@ -64,94 +68,9 @@ class ReportController extends Controller ]); } - private function getSurgicalPatientsFromSnapshot(string $type, array $reportIds) - { - $count = 0; - switch ($type) { - case 'emergency': - $count = $this->getMetrikaResult(10, $reportIds); - break; - case 'plan': - $count = $this->getMetrikaResult(11, $reportIds); - break; - case 'recipient': - $count = $this->getMetrikaResult(3, $reportIds); - break; - } - - return $count; - } - - private function getPatientsCountFromSnapshot(string $type, array $reportIds) - { - $count = 0; - switch ($type) { - case 'emergency': - $count = $this->getMetrikaResult(10, $reportIds); - break; - case 'plan': - $count = $this->getMetrikaResult(11, $reportIds); - break; - case 'recipient': - $count = $this->getMetrikaResult(3, $reportIds); - break; - case 'outcome': - $count = $this->getMetrikaResult(7, $reportIds); - break; - case 'deceased': - $count = $this->getMetrikaResult(9, $reportIds); - break; - case 'current': - $count = $this->getMetrikaResult(8, $reportIds); - break; - } - - return $count; - } - - private function getMetrikaResult(int $metrikaItemId, array $reportIds) - { - $reports = Report::whereIn('report_id', $reportIds) - ->with('metrikaResults') - ->get(); - - $count = 0; - foreach ($reports as $report) { - foreach ($report->metrikaResults as $metrikaResult) { - if ($metrikaResult->rf_metrika_item_id === $metrikaItemId) { - $count += intval($metrikaResult->value) ?? 0; - } - } - } - - return $count; - } - - /** - * Получить ID поступивших пациентов из снапшотов - */ - private function getRecipientIdsFromSnapshots(array $reportIds) - { - $recipientIds = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) - ->where('patient_type', 'recipient') - ->pluck('rf_medicalhistory_id') - ->unique() - ->toArray(); - - return $recipientIds; - } - public function store(Request $request) { $user = Auth::user(); - $misDepartmentId = $user->department->rf_mis_department_id; - - $branchId = MisStationarBranch::where('rf_DepartmentID', $misDepartmentId) - ->value('StationarBranchID'); - - // Определяем, является ли пользователь заведующим/администратором - $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); - $data = $request->validate([ 'metrics' => 'required', 'observationPatients' => 'nullable', @@ -162,6 +81,27 @@ class ReportController extends Controller 'userId' => 'required|integer', 'reportId' => 'nullable', ]); + $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); + + if ($this->reportSavePathService->usesNewArchitecture()) { + $this->reportSavePathService->saveManual($user, [ + ...$data, + 'dates' => [(int) $data['startAt'], (int) $data['endAt']], + ]); + return response()->json([ + 'status' => 'ok', + 'path' => 'new', + ]); + } + + $misDepartmentId = $user->department->rf_mis_department_id; + + $branchId = MisStationarBranch::where('rf_DepartmentID', $misDepartmentId) + ->value('StationarBranchID'); + + // Определяем, является ли пользователь заведующим/администратором + $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); + $metrics = $data['metrics']; $observationPatients = $data['observationPatients']; $unwantedEvents = $data['unwantedEvents']; @@ -291,11 +231,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 16, + 'rf_metrika_item_id' => MetrikaConfig::UNWANTED_EVENTS, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 16, + 'rf_metrika_item_id' => MetrikaConfig::UNWANTED_EVENTS, 'value' => count($unwantedEvents), ] ); @@ -338,11 +278,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 14, + 'rf_metrika_item_id' => MetrikaConfig::OBSERVATION, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 14, + 'rf_metrika_item_id' => MetrikaConfig::OBSERVATION, 'value' => count($observationPatients), ] ); @@ -360,11 +300,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 4, + 'rf_metrika_item_id' => MetrikaConfig::PLAN, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 4, + 'rf_metrika_item_id' => MetrikaConfig::PLAN, 'value' => $planCount, ] ); @@ -381,11 +321,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 12, + 'rf_metrika_item_id' => MetrikaConfig::EMERGENCY, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 12, + 'rf_metrika_item_id' => MetrikaConfig::EMERGENCY, 'value' => $emergencyCount, ] ); @@ -401,11 +341,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 15, + 'rf_metrika_item_id' => MetrikaConfig::DISCHARGED, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 15, + 'rf_metrika_item_id' => MetrikaConfig::DISCHARGED, 'value' => count($dischargedIds), ] ); @@ -421,11 +361,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 13, + 'rf_metrika_item_id' => MetrikaConfig::TRANSFERRED, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 13, + 'rf_metrika_item_id' => MetrikaConfig::TRANSFERRED, 'value' => count($transferredIds), ] ); @@ -441,11 +381,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 9, + 'rf_metrika_item_id' => MetrikaConfig::DECEASED, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 9, + 'rf_metrika_item_id' => MetrikaConfig::DECEASED, 'value' => count($deceasedIds), ] ); @@ -471,11 +411,11 @@ class ReportController extends Controller MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 3, + 'rf_metrika_item_id' => MetrikaConfig::RECIPIENT, ], [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 3, + 'rf_metrika_item_id' => MetrikaConfig::RECIPIENT, 'value' => count($recipientIds), ] ); @@ -675,258 +615,6 @@ class ReportController extends Controller ]); } - /** - * Получить пациентов (плановых или экстренных) - */ - private function getPlanOrEmergencyPatients( - ?string $status, - bool $isHeadOrAdmin, - $branchId, - $startDate, $endDate, - bool $returnedCount = false, - bool $all = false, - bool $onlyIds = false, - bool $today = false - ) { - // Определяем, является ли статус outcome - $isOutcomeStatus = in_array($status, ['outcome-transferred', 'outcome-discharged', 'outcome-deceased']); - - if ($isOutcomeStatus) { - $visitResultIds = match ($status) { - 'outcome-transferred' => [4, 14], - 'outcome-discharged' => [1, 11, 2, 12, 7, 18, 48], - 'outcome-deceased' => [5, 6, 15, 16], - default => [], - }; - - $historyQuery = $this->buildOutcomeMedicalHistoryQuery( - $branchId, - $startDate, - $endDate, - $visitResultIds - ); - - if ($onlyIds) { - return $historyQuery->pluck('MedicalHistoryID')->values(); - } - - if ($returnedCount) { - return $historyQuery->count(); - } - - return $historyQuery - ->with(['surgicalOperations' => function ($query) use ($startDate, $endDate) { - $query->where('Date', '>=', $startDate) - ->where('Date', '<=', $endDate); - }]) - ->orderBy('DateRecipient', 'DESC') - ->get(); - } else { - // Разная логика для заведующего и врача - if ($isHeadOrAdmin) { - // Заведующий: используем whereInDepartment - $query = MisMigrationPatient::whereInDepartment($branchId) -// ->whereBetween('DateIngoing', [$startDate, $endDate]); - ->where('DateIngoing', '>=', $startDate) - ->where('DateIngoing', '<=', $endDate); - } else { - // Врач: используем currentlyInTreatment + фильтр по дате - $query = MisMigrationPatient::currentlyInTreatment($branchId) - ->when($today, function ($query) use ($startDate, $endDate) { - // return $query->whereBetween('DateIngoing', [$startDate, $endDate]); - return $query->where('DateIngoing', '>=', $startDate) - ->where('DateIngoing', '<=', $endDate); - }); - } - } - - $medicalHistoryIds = $query->pluck('rf_MedicalHistoryID')->toArray(); - - if (empty($medicalHistoryIds)) { - if ($returnedCount) { - return 0; - } - - return collect(); - } - - // Получаем истории - $query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - ->with(['surgicalOperations' => function ($query) use ($startDate, $endDate) { - // $query->whereBetween('Date', [$startDate, $endDate]); - $query->where('Date', '>=', $startDate) - ->where('Date', '<=', $endDate); - }]) - ->orderBy('DateRecipient', 'DESC'); - - // Выбираем план или экстренность - if (! $all && ! $isOutcomeStatus) { - if ($status === 'plan') { - $query->plan(); - } elseif ($status === 'emergency') { - $query->emergency(); - } - } - - // Для врача добавляем условие "в отделении" - if (! $isHeadOrAdmin && ! $isOutcomeStatus) { - $query->currentlyHospitalized(); - } - - if ($onlyIds) { - return $query->select('MedicalHistoryID') - ->pluck('MedicalHistoryID')->values(); - } else { - if ($returnedCount) { - return $query->count(); - } else { - return $query->get(); - } - } - } - - /** - * Получить всех выбывших пациентов - */ - private function getAllOutcomePatients($branchId, $startDate, $endDate, bool $returnedCount = false) - { - $query = $this->buildOutcomeMedicalHistoryQuery($branchId, $startDate, $endDate); - - if ($returnedCount) { - return $query->count(); - } - - return $query - ->with(['surgicalOperations']) - ->orderBy('DateRecipient', 'DESC') - ->get(); - } - - /** - * Получить понятное название типа выбытия - */ - private function getOutcomeTypeName($visitResultId): string - { - return match ($visitResultId) { - 1, 7, 8, 9, 10, 11, 48, 49, 124 => 'Выписка', - 2, 3, 4, 12, 13, 14 => 'Перевод', - 5, 6, 15, 16 => 'Умер', - // Добавьте другие коды по мере необходимости - default => 'Другое ('.$visitResultId.')' - }; - } - - /** - * Получить умерших пациентов (исход) - */ - private function getDeceasedOutcomePatients($branchId, $startDate, $endDate, bool $returnedCount = false, bool $onlyIds = false) - { - $query = $this->buildOutcomeMedicalHistoryQuery( - $branchId, - $startDate, - $endDate, - [5, 6, 15, 16] - ); - - if ($onlyIds) { - return $query->pluck('MedicalHistoryID')->values(); - } - - if ($returnedCount) { - return $query->count(); - } else { - return $query - ->with(['surgicalOperations']) - ->orderBy('DateRecipient', 'DESC') - ->get(); - } - } - - private function buildOutcomeMedicalHistoryQuery( - int $branchId, - string $startDate, - string $endDate, - ?array $visitResultIds = null - ) { - $startDateOnly = Carbon::parse($startDate)->toDateString(); - $endDateOnly = Carbon::parse($endDate)->toDateString(); - - return MisMedicalHistory::query() - ->where('MedicalHistoryID', '<>', 0) - ->whereDate('DateExtract', '>', $startDateOnly) - ->whereDate('DateExtract', '<=', $endDateOnly) - ->whereHas('migrations', function ($migrationQuery) use ($branchId, $visitResultIds) { - $migrationQuery->where('rf_StationarBranchID', $branchId); - - if ($visitResultIds !== null && ! empty($visitResultIds)) { - $migrationQuery->whereIn('rf_kl_VisitResultID', $visitResultIds); - } - }); - } - - /** - * Получить пациентов с операциями - */ - private function getSurgicalPatients(string $status, bool $isHeadOrAdmin, $branchId, $startDate, $endDate, bool $returnedCount = false) - { - $query = MisSurgicalOperation::where('rf_StationarBranchID', $branchId) - ->completed() -// ->whereBetween('Date', [$startDate, $endDate]) - ->where('Date', '>=', $startDate) - ->where('Date', '<=', $endDate) - ->orderBy('Date', 'DESC'); - - if ($status === 'plan') { - $query->where('rf_TypeSurgOperationInTimeID', 6); - } else { - $query->whereIn('rf_TypeSurgOperationInTimeID', [4, 5]); - } - - if ($returnedCount) { - return $query->count(); - } else { - return $query->get(); - } - } - - /** - * Находятся на лечении - */ - private function getCurrentPatients($branchId, bool $returnedCount = false, bool $onlyIds = false) - { - // $currentCount = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - // ->currentlyHospitalized() - // ->orderBy('DateRecipient', 'DESC') - // ->count(); - $medicalHistoryIds = MisMigrationPatient::currentlyInTreatment($branchId) - ->pluck('rf_MedicalHistoryID') - ->unique() - ->toArray(); - - if (empty($medicalHistoryIds)) { - if ($returnedCount) { - return 0; - } - - return collect(); - } - - if ($onlyIds) { - return $medicalHistoryIds; - } - - $patients = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - ->currentlyHospitalized() - ->with(['surgicalOperations']) - ->orderBy('DateRecipient', 'DESC'); - - if ($returnedCount) { - return $patients->count(); - } else { - return $patients->get(); - } - } - public function removeObservation( Request $request, ) { diff --git a/app/Http/Controllers/Web/ReportController.php b/app/Http/Controllers/Web/ReportController.php index d0afdc4..264d002 100644 --- a/app/Http/Controllers/Web/ReportController.php +++ b/app/Http/Controllers/Web/ReportController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Web; +use App\Application\Reports\ReportSavePathService; use App\Exports\ReportPageExport; use App\Http\Controllers\Controller; use App\Http\Resources\Mis\FormattedPatientResource; @@ -20,7 +21,8 @@ class ReportController extends Controller public function __construct( protected ReportPageService $reportPageService, protected ReportService $reportService, - protected DateRangeService $dateRangeService + protected DateRangeService $dateRangeService, + protected ReportSavePathService $reportSavePathService, ) {} public function index(Request $request) @@ -46,7 +48,7 @@ class ReportController extends Controller 'status' => 'nullable|in:draft,submitted', ]); - $this->reportService->storeReport($validated, Auth::user(), false); + $this->reportSavePathService->saveManual(Auth::user(), $validated); return redirect()->route('start'); } diff --git a/app/Infrastructure/Reports/Adapters/LegacyReportServiceAdapter.php b/app/Infrastructure/Reports/Adapters/LegacyReportServiceAdapter.php new file mode 100644 index 0000000..96c1958 --- /dev/null +++ b/app/Infrastructure/Reports/Adapters/LegacyReportServiceAdapter.php @@ -0,0 +1,146 @@ +reportService->prepareForHeavySave(); + } + + /** + * @param array $payload + * @throws \Exception + */ + public function buildSnapshotFromPayload(array $payload, string $reportType = 'daily'): ReportSnapshot + { + $start = $this->dateRangeService->toCarbon($payload['dates'][0] ?? $payload['period_start'] ?? null); + $end = $this->dateRangeService->toCarbon($payload['dates'][1] ?? $payload['period_end'] ?? null); + + return new ReportSnapshot( + departmentId: (int) $payload['departmentId'], + userId: (int) $payload['userId'], + actorUserId: isset($payload['actorUserId']) ? (int) $payload['actorUserId'] : null, + periodStart: new DateTimeImmutable($start->format('Y-m-d H:i:s')), + periodEnd: new DateTimeImmutable($end->format('Y-m-d H:i:s')), + status: (string) ($payload['status'] ?? 'draft'), + autoFill: (bool) ($payload['autoFill'] ?? false), + metrics: MetrikaConfig::normalizeMetrics((array) ($payload['metrics'] ?? [])), + observationPatients: $this->normalizeObservationPatients((array) ($payload['observationPatients'] ?? [])), + unwantedEvents: $this->normalizeUnwantedEvents((array) ($payload['unwantedEvents'] ?? [])), + reportId: isset($payload['reportId']) ? (int) $payload['reportId'] : null, + createdAt: isset($payload['created_at']) ? new DateTimeImmutable((string) $payload['created_at']) : null, + sentAt: isset($payload['sent_at']) ? new DateTimeImmutable((string) $payload['sent_at']) : null, + reportType: $reportType, + ); + } + + public function buildSnapshotFromInput(GenerateReportInput $input): ReportSnapshot + { + if ($input->rawPayload !== null) { + return $this->buildSnapshotFromPayload([ + ...$input->rawPayload, + 'actorUserId' => $input->actorUserId, + 'autoFill' => $input->autoFill, + ], $input->reportType); + } + + return $input->toSnapshot(); + } + + public function createPatientSnapshots(Report $report, User $user, ReportSnapshot $snapshot, bool $autoFill): void + { + $this->snapshotService->createPatientSnapshots( + report: $report, + user: $user, + dates: [ + $snapshot->periodStart->getTimestamp(), + $snapshot->periodEnd->getTimestamp(), + ], + fillableAuto: $autoFill, + ); + } + + public function syncCalculatedMetrics(Report $report, User $user, ReportSnapshot $snapshot): void + { + $this->reportService->syncCalculatedMetricsForStoredReport( + $report, + $user, + [ + 'dates' => [ + $snapshot->periodStart->getTimestamp(), + $snapshot->periodEnd->getTimestamp(), + ], + ], + ); + } + + public function finalizeStoredReport(Report $report): void + { + $this->reportService->finalizeStoredReport($report); + } + + public function saveLethalMetricFromSnapshots(Report $report): void + { + $this->reportService->saveLethalMetricForStoredReport($report); + } + + public function clearCacheAfterReportCreation(User $user, Report $report): void + { + $this->reportService->clearCacheAfterStoredReport($user, $report); + } + + public function buildAutoFillPayload(User $user, Department $department, DateRange $dateRange): array + { + return $this->reportService->buildAutoFillReportPayload($user, $department, $dateRange); + } + + /** + * @param array> $patients + * @return array> + */ + 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> $events + * @return array> + */ + private function normalizeUnwantedEvents(array $events): array + { + return array_values(array_map(static function (array $event): array { + return [ + 'title' => $event['title'] ?? '', + 'comment' => $event['comment'] ?? '', + 'is_visible' => (bool) ($event['is_visible'] ?? true), + ]; + }, $events)); + } +} diff --git a/app/Infrastructure/Reports/Logging/ReportsAuditLogger.php b/app/Infrastructure/Reports/Logging/ReportsAuditLogger.php new file mode 100644 index 0000000..3e66ca4 --- /dev/null +++ b/app/Infrastructure/Reports/Logging/ReportsAuditLogger.php @@ -0,0 +1,26 @@ +info('reports.comparison', [ + 'report_type' => $result->reportType, + 'path' => $result->path, + 'status' => $result->status, + 'department_id' => $result->departmentId, + 'user_id' => $result->userId, + 'period_start' => $result->periodStart, + 'period_end' => $result->periodEnd, + 'report_id' => $result->reportId, + 'diff' => $result->diff, + 'duration_ms' => $result->durationMs, + ]); + } +} diff --git a/app/Infrastructure/Reports/Repositories/EloquentReportRepository.php b/app/Infrastructure/Reports/Repositories/EloquentReportRepository.php new file mode 100644 index 0000000..bade750 --- /dev/null +++ b/app/Infrastructure/Reports/Repositories/EloquentReportRepository.php @@ -0,0 +1,113 @@ +reportStorageService = $reportStorageService ?? app(ReportStorageService::class); + $this->reportMetricsFinalizer = $reportMetricsFinalizer ?? app(ReportMetricsFinalizer::class); + } + + private readonly ReportStorageService $reportStorageService; + + private readonly ReportMetricsFinalizer $reportMetricsFinalizer; + + public function save(ReportSnapshot $snapshot): SavedReportResult + { + $this->legacyAdapter->prepareMemoryForHeavySave(); + $actor = $snapshot->actorUserId ? User::query()->find($snapshot->actorUserId) : null; + + if (! $actor) { + throw new RuntimeException('Actor user was not found.'); + } + + $report = DB::transaction(function () use ($snapshot, $actor) { + $report = $this->reportStorageService->createOrUpdateReport($snapshot, $actor); + $this->reportStorageService->saveMetrics($report, $snapshot); + $this->reportStorageService->saveUnwantedEvents($report, $snapshot); + $this->reportStorageService->saveObservationPatients($report, $snapshot); + $this->legacyAdapter->createPatientSnapshots($report, $actor, $snapshot, $snapshot->autoFill); + $this->legacyAdapter->syncCalculatedMetrics($report, $actor, $snapshot); + + return $report; + }); + + DB::transaction(function () use ($report) { + $this->reportMetricsFinalizer->finalize($report); + $this->legacyAdapter->saveLethalMetricFromSnapshots($report); + }); + + $this->legacyAdapter->clearCacheAfterReportCreation($actor, $report); + + $savedSnapshot = $this->findSnapshot((int) $report->report_id); + + if (! $savedSnapshot) { + throw new RuntimeException('Saved report could not be reloaded for comparison.'); + } + + return new SavedReportResult( + reportId: (int) $report->report_id, + snapshot: $savedSnapshot, + ); + } + + public function findSnapshot(int $reportId): ?ReportSnapshot + { + /** @var Report|null $report */ + $report = Report::query() + ->with(['metrikaResults', 'observationPatients', 'unwantedEvents']) + ->find($reportId); + + if (! $report || ! $report->period_start || ! $report->period_end) { + return null; + } + + return new ReportSnapshot( + departmentId: (int) $report->rf_department_id, + userId: (int) ($report->rf_lpudoctor_id ?? $report->rf_user_id), + actorUserId: (int) $report->rf_user_id, + periodStart: new \DateTimeImmutable($report->period_start->format('Y-m-d H:i:s')), + periodEnd: new \DateTimeImmutable($report->period_end->format('Y-m-d H:i:s')), + status: (string) ($report->status ?? 'draft'), + autoFill: false, + metrics: $report->metrikaResults + ->mapWithKeys(fn (MetrikaResult $metric) => [(int) $metric->rf_metrika_item_id => $metric->value]) + ->all(), + observationPatients: $report->observationPatients + ->map(fn (ObservationPatient $patient) => [ + 'medical_history_id' => $patient->rf_medicalhistory_id, + 'department_patient_id' => $patient->rf_department_patient_id, + 'comment' => $patient->comment, + ])->values()->all(), + unwantedEvents: $report->unwantedEvents + ->map(fn (UnwantedEvent $event) => [ + 'title' => $event->title, + 'comment' => $event->comment, + 'is_visible' => (bool) $event->is_visible, + ])->values()->all(), + reportId: (int) $report->report_id, + createdAt: $report->created_at ? new \DateTimeImmutable($report->created_at->format('Y-m-d H:i:s')) : null, + sentAt: $report->sent_at ? new \DateTimeImmutable($report->sent_at->format('Y-m-d H:i:s')) : null, + ); + } + +} diff --git a/app/Infrastructure/Reports/Services/AutoFillReportPayloadBuilder.php b/app/Infrastructure/Reports/Services/AutoFillReportPayloadBuilder.php new file mode 100644 index 0000000..14a90d6 --- /dev/null +++ b/app/Infrastructure/Reports/Services/AutoFillReportPayloadBuilder.php @@ -0,0 +1,175 @@ +getBranchId($department->rf_mis_department_id); + $metrics = $this->buildMetrics($department, $user, $branchId, $dateRange); + + return [ + 'departmentId' => $department->department_id, + 'userId' => $user->rf_lpudoctor_id ?? $user->id, + 'dates' => [ + $dateRange->startTimestamp(), + $dateRange->endTimestamp(), + ], + 'sent_at' => $dateRange->endSql(), + 'created_at' => $dateRange->endSql(), + 'status' => 'submitted', + 'metrics' => [ + MetrikaConfig::payloadKey(MetrikaConfig::PLAN) => $metrics['plan'], + MetrikaConfig::payloadKey(MetrikaConfig::EMERGENCY) => $metrics['emergency'], + MetrikaConfig::payloadKey(MetrikaConfig::RECIPIENT) => $metrics['recipient'], + MetrikaConfig::payloadKey(MetrikaConfig::OUTCOME) => $metrics['discharged'] + $metrics['deceased'], + MetrikaConfig::payloadKey(MetrikaConfig::CURRENT) => $metrics['current'], + MetrikaConfig::payloadKey(MetrikaConfig::DECEASED) => $metrics['deceased'], + MetrikaConfig::payloadKey(MetrikaConfig::EMERGENCY_SURGERY) => $metrics['emergency_surgery'], + MetrikaConfig::payloadKey(MetrikaConfig::PLAN_SURGERY) => $metrics['plan_surgery'], + MetrikaConfig::payloadKey(MetrikaConfig::TRANSFERRED) => $metrics['transferred'], + MetrikaConfig::payloadKey(MetrikaConfig::OBSERVATION) => 0, + MetrikaConfig::payloadKey(MetrikaConfig::DISCHARGED) => $metrics['discharged'], + ], + 'observationPatients' => [], + 'unwantedEvents' => [], + ]; + } + + private function buildMetrics(Department $department, User $user, ?int $branchId, DateRange $dateRange): array + { + if (! $branchId) { + return [ + 'plan' => 0, + 'emergency' => 0, + 'recipient' => 0, + 'discharged' => 0, + 'transferred' => 0, + 'deceased' => 0, + 'current' => 0, + 'plan_surgery' => 0, + 'emergency_surgery' => 0, + ]; + } + + $manualSurgicalCount = $this->calculatedMetricsSynchronizer->getManualSurgicalCounts($department, $dateRange); + $recipientQuery = $this->buildRecipientMedicalHistoryQuery($branchId, $dateRange); + $dischargeCodes = [1, 11, 2, 12, 7, 18, 48]; + $deceasedCodes = [5, 6, 15, 16]; + $transferCodes = [4, 14]; + + $planRecipient = (clone $recipientQuery) + ->where('rf_EmerSignID', 1) + ->distinct() + ->count('MedicalHistoryID'); + + $emergencyRecipient = (clone $recipientQuery) + ->whereIn('rf_EmerSignID', [2, 4]) + ->distinct() + ->count('MedicalHistoryID'); + + $recipientTotal = (clone $recipientQuery) + ->distinct() + ->count('MedicalHistoryID'); + + $discharged = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $dischargeCodes); + $deceased = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $deceasedCodes); + $transferred = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $transferCodes); + + return [ + 'plan' => $planRecipient, + 'emergency' => $emergencyRecipient, + 'recipient' => $recipientTotal, + 'discharged' => $discharged, + 'transferred' => $transferred, + 'deceased' => $deceased, + 'current' => $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'current', $dateRange, $branchId, null, true), + 'plan_surgery' => $this->patientService->getSurgicalPatients('plan', $branchId, $dateRange, true) + ($manualSurgicalCount[1] ?? 0), + 'emergency_surgery' => $this->patientService->getSurgicalPatients('emergency', $branchId, $dateRange, true) + ($manualSurgicalCount[0] ?? 0), + ]; + } + + private function buildRecipientMedicalHistoryQuery(int $branchId, DateRange $dateRange) + { + $startAt = $dateRange->start()->copy()->subDay()->format('Y-m-d H:i:s'); + $endAt = $dateRange->end()->copy()->addDay()->format('Y-m-d H:i:s'); + + if ($dateRange->isOneDay) { + $startAt = $dateRange->startSql(); + $endAt = $dateRange->endSql(); + } + + return MisMedicalHistory::query() + ->where('MedicalHistoryID', '<>', 0) + ->whereExists(function ($query) use ($branchId, $startAt, $endAt) { + $query->select(DB::raw(1)) + ->from('stt_migrationpatient as mp') + ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') + ->where('mp.rf_StationarBranchID', $branchId) + ->where('mp.DateIngoing', '>', $startAt) + ->where('mp.DateIngoing', '<=', $endAt); + }); + } + + private function buildTreatedMedicalHistoryQuery(int $branchId, DateRange $dateRange) + { + $query = MisMedicalHistory::query() + ->where('MedicalHistoryID', '<>', 0) + ->whereExists(function ($query) use ($branchId) { + $query->select(DB::raw(1)) + ->from('stt_migrationpatient as mp') + ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') + ->where('mp.rf_StationarBranchID', $branchId); + }); + + if ($dateRange->isOneDay) { + return $query + ->where('DateExtract', '>', $dateRange->startSql()) + ->where('DateExtract', '<=', $dateRange->endSql()); + } + + $startAt = $dateRange->startSql(); + $endDate = $dateRange->end()->toDateString(); + + return $query + ->where('DateExtract', '>', $startAt) + ->whereDate('DateExtract', '<=', $endDate); + } + + private function countOutcomeByVisitResultIds(int $branchId, DateRange $dateRange, array $visitResultIds): int + { + return $this->buildTreatedMedicalHistoryQuery($branchId, $dateRange) + ->whereExists(function ($query) use ($branchId, $visitResultIds) { + $query->select(DB::raw(1)) + ->from('stt_migrationpatient as mp') + ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') + ->where('mp.rf_StationarBranchID', $branchId) + ->whereIn('mp.rf_kl_VisitResultID', $visitResultIds); + }) + ->distinct() + ->count('MedicalHistoryID'); + } + + private function getBranchId(int $misDepartmentId): ?int + { + return MisStationarBranch::where('rf_DepartmentID', $misDepartmentId) + ->value('StationarBranchID'); + } +} diff --git a/app/Infrastructure/Reports/Services/CalculatedMetricsSynchronizer.php b/app/Infrastructure/Reports/Services/CalculatedMetricsSynchronizer.php new file mode 100644 index 0000000..d31c77f --- /dev/null +++ b/app/Infrastructure/Reports/Services/CalculatedMetricsSynchronizer.php @@ -0,0 +1,141 @@ + $data + */ + public function sync(Report $report, User $user, array $data): void + { + if (! isset($data['dates'][0], $data['dates'][1])) { + return; + } + + $department = Department::query()->where('department_id', $report->rf_department_id)->first(); + if (! $department) { + return; + } + + $dateRange = $this->dateRangeService->getNormalizedDateRange( + $user, + (string) $data['dates'][0], + (string) $data['dates'][1] + ); + + $branchId = $this->getBranchId($department->rf_mis_department_id); + + $planCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['plan']); + $emergencyCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['emergency']); + $recipientCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['recipient']); + $dischargedCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['discharged']); + $transferredCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['transferred']); + $deceasedCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['deceased']); + $currentCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['current']); + $outcomeCount = $dischargedCount + $deceasedCount; + + $manualSurgicalCount = $this->getManualSurgicalCounts($department, $dateRange); + $misEmergencySurgery = $branchId + ? $this->patientService->getSurgicalPatients('emergency', $branchId, $dateRange, true) + : 0; + $misPlanSurgery = $branchId + ? $this->patientService->getSurgicalPatients('plan', $branchId, $dateRange, true) + : 0; + + $observationCount = ObservationPatient::query() + ->where('rf_department_id', $department->department_id) + ->where('rf_report_id', $report->report_id) + ->count(); + + $unwantedEventsCount = UnwantedEvent::query() + ->where('rf_report_id', $report->report_id) + ->count(); + + $this->reportStorageService->saveMetric($report, MetrikaConfig::RECIPIENT, $recipientCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::PLAN, $planCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::OUTCOME, $outcomeCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::CURRENT, $currentCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::DECEASED, $deceasedCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::EMERGENCY_SURGERY, $misEmergencySurgery + ($manualSurgicalCount[0] ?? 0)); + $this->reportStorageService->saveMetric($report, MetrikaConfig::PLAN_SURGERY, $misPlanSurgery + ($manualSurgicalCount[1] ?? 0)); + $this->reportStorageService->saveMetric($report, MetrikaConfig::EMERGENCY, $emergencyCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::TRANSFERRED, $transferredCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::OBSERVATION, $observationCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::DISCHARGED, $dischargedCount); + $this->reportStorageService->saveMetric($report, MetrikaConfig::UNWANTED_EVENTS, $unwantedEventsCount); + } + + private function getBranchId(int $misDepartmentId): ?int + { + return MisStationarBranch::where('rf_DepartmentID', $misDepartmentId) + ->value('StationarBranchID'); + } + + private function countUniqueSnapshotsForTypes(int $reportId, array $patientTypes): int + { + return MedicalHistorySnapshot::query() + ->where('rf_report_id', $reportId) + ->whereIn('patient_type', $patientTypes) + ->get(['medical_history_snapshot_id', 'patient_uid', 'rf_medicalhistory_id']) + ->map(function (MedicalHistorySnapshot $snapshot) { + return $snapshot->patient_uid + ?: ($snapshot->rf_medicalhistory_id + ? "mis:{$snapshot->rf_medicalhistory_id}" + : "snapshot:{$snapshot->medical_history_snapshot_id}"); + }) + ->unique() + ->count(); + } + + public function getManualSurgicalCounts(Department $department, DateRange $dateRange): array + { + $baseQuery = DepartmentPatientOperation::query() + ->whereBetween('started_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->whereHas('patient', function ($query) use ($department) { + $query->where('rf_department_id', $department->department_id) + ->whereIn('source_type', ['manual', 'special']); + }); + + $emergencyCount = (clone $baseQuery) + ->where(function ($query) { + $query->where('urgency', 'emergency') + ->orWhere(function ($fallback) { + $fallback->whereNull('urgency') + ->whereHas('patient', fn ($patientQuery) => $patientQuery->where('patient_kind', 'emergency')); + }); + }) + ->count(); + + $planCount = (clone $baseQuery) + ->where(function ($query) { + $query->where('urgency', 'plan') + ->orWhere(function ($fallback) { + $fallback->whereNull('urgency') + ->whereHas('patient', fn ($patientQuery) => $patientQuery->where('patient_kind', 'plan')); + }); + }) + ->count(); + + return [$emergencyCount, $planCount]; + } +} diff --git a/app/Infrastructure/Reports/Services/ReportMetricsFinalizer.php b/app/Infrastructure/Reports/Services/ReportMetricsFinalizer.php new file mode 100644 index 0000000..3a4a73b --- /dev/null +++ b/app/Infrastructure/Reports/Services/ReportMetricsFinalizer.php @@ -0,0 +1,154 @@ +saveBedDaysMetrics($report); + $this->savePreoperativeMetrics($report); + $this->saveDepartmentLoadMetric($report); + } + + private function saveBedDaysMetrics(Report $report): void + { + $result = $this->bedDaysCalculator->calculate($this->buildStayIntervals($report)); + + $this->reportStorageService->saveMetric($report, MetrikaConfig::TOTAL_BED_DAYS, $result->total); + $this->reportStorageService->saveMetric($report, MetrikaConfig::AVERAGE_BED_DAYS, $result->average); + } + + private function savePreoperativeMetrics(Report $report): void + { + $result = $this->preoperativeDaysCalculator->calculate($this->buildOperationIntervals($report)); + + $this->reportStorageService->saveMetric($report, MetrikaConfig::TOTAL_PREOPERATIVE_DAYS, $result->total); + $this->reportStorageService->saveMetric($report, MetrikaConfig::PREOPERATIVE_PATIENT_COUNT, $result->count); + $this->reportStorageService->saveMetric($report, MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS, $result->average); + } + + private function saveDepartmentLoadMetric(Report $report): void + { + $currentCount = (float) ($report->metrikaResults()->where('rf_metrika_item_id', MetrikaConfig::CURRENT)->value('value') ?? 0); + $bedsCount = (float) ($report->metrikaResults()->where('rf_metrika_item_id', MetrikaConfig::BEDS)->value('value') ?? 0); + + $this->reportStorageService->saveMetric( + $report, + MetrikaConfig::DEPARTMENT_LOADED, + $this->departmentLoadCalculator->calculate($currentCount, $bedsCount), + ); + } + + /** + * @return array + */ + private function buildStayIntervals(Report $report): array + { + $snapshots = MedicalHistorySnapshot::query() + ->where('rf_report_id', $report->report_id) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->with('medicalHistory') + ->get(); + + $intervals = []; + + foreach ($snapshots as $snapshot) { + $history = $snapshot->medicalHistory; + + if (! $history) { + continue; + } + + $startRaw = $history->DateRecipientHS ?? $history->DateRecipient ?? null; + $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)) { + $endRaw = $history->DateDeath; + } elseif ($history->DateExtract && ! in_array($history->DateExtract->format('Y-m-d'), ['1900-01-01', '2222-01-01'], true)) { + $endRaw = $history->DateExtract; + } + } elseif ($history->DateExtract && ! in_array($history->DateExtract->format('Y-m-d'), ['1900-01-01', '2222-01-01'], true)) { + $endRaw = $history->DateExtract; + } + + if (! $startRaw || ! $endRaw) { + continue; + } + + $intervals[] = new StayInterval( + startAt: new DateTimeImmutable((string) $startRaw), + endAt: new DateTimeImmutable((string) $endRaw), + ); + } + + return $intervals; + } + + /** + * @return array + */ + private function buildOperationIntervals(Report $report): array + { + $patientIds = MedicalHistorySnapshot::query() + ->where('rf_report_id', $report->report_id) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->pluck('rf_medicalhistory_id') + ->unique() + ->values(); + + if ($patientIds->isEmpty()) { + return []; + } + + $rows = DB::table('stt_medicalhistory as mh') + ->join('stt_surgicaloperation as so', 'so.rf_MedicalHistoryID', '=', 'mh.MedicalHistoryID') + ->whereIn('mh.MedicalHistoryID', $patientIds) + ->whereNotNull('so.Date') + ->select( + 'mh.MedicalHistoryID', + DB::raw('MIN(so."Date") as first_operation'), + 'mh.DateRecipientHS', + 'mh.DateRecipient' + ) + ->groupBy('mh.MedicalHistoryID', 'mh.DateRecipientHS', 'mh.DateRecipient') + ->get(); + + $intervals = []; + + foreach ($rows as $row) { + $startRaw = $row->DateRecipientHS ?? $row->DateRecipient ?? null; + $operationRaw = $row->first_operation ?? null; + + if (! $startRaw || ! $operationRaw) { + continue; + } + + $intervals[] = new OperationInterval( + admittedAt: new DateTimeImmutable((string) $startRaw), + operationAt: new DateTimeImmutable((string) $operationRaw), + ); + } + + return $intervals; + } +} diff --git a/app/Infrastructure/Reports/Services/ReportPatientsReadService.php b/app/Infrastructure/Reports/Services/ReportPatientsReadService.php new file mode 100644 index 0000000..d43f333 --- /dev/null +++ b/app/Infrastructure/Reports/Services/ReportPatientsReadService.php @@ -0,0 +1,398 @@ +parseScopedStatus($status); + $branchId = $this->contextResolver->resolveBranchId($department); + + if (! $branchId) { + return collect(); + } + + if ($sourceScope === 'special' || $baseStatus === 'reanimation') { + return $this->getPatientsFromReplica( + $department, + $user, + $status, + $dateRange, + $branchId, + $onlyIds, + $includeCurrentPatients + ); + } + + $useSnapshots = ! $this->contextResolver->shouldUseReplicaForLiveStatus($user, $baseStatus, $dateRange) + && $this->contextResolver->shouldUseSnapshots($department, $dateRange, $beforeCreate); + + if ($useSnapshots) { + return $this->getPatientsFromSnapshots($department, $status, $dateRange, $branchId, $onlyIds); + } + + return $this->getPatientsFromReplica( + $department, + $user, + $status, + $dateRange, + $branchId, + $onlyIds, + $includeCurrentPatients + ); + } + + /** + * Посчитать пациентов отчёта по запрошенному статусу и области источника. + */ + public function getPatientsCountByStatus( + Department $department, + User $user, + string $status, + DateRange $dateRange + ): int { + [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); + $branchId = $this->contextResolver->resolveBranchId($department); + + if (! $branchId) { + return 0; + } + + if ($sourceScope === 'special' || $baseStatus === 'reanimation') { + return $this->getPatientsCountFromReplica($department, $user, $status, $dateRange, $branchId); + } + + $useSnapshots = ! $this->contextResolver->shouldUseReplicaForLiveStatus($user, $baseStatus, $dateRange) + && $this->contextResolver->shouldUseSnapshots($department, $dateRange); + + if ($useSnapshots) { + return $this->getPatientsCountFromSnapshots($department, $status, $dateRange, $branchId); + } + + return $this->getPatientsCountFromReplica($department, $user, $status, $dateRange, $branchId); + } + + /** + * Построить карту счётчиков пациентов по scope для интерфейса. + */ + public function getPatientsCountsMap(Department $department, User $user, DateRange $dateRange): array + { + $baseStatuses = [ + 'plan', + 'emergency', + 'observation', + 'reanimation', + 'outcome-discharged', + 'outcome-deceased', + 'outcome-transferred', + ]; + + $counts = [ + 'mis-plan' => 0, + 'mis-emergency' => 0, + 'mis-observation' => 0, + 'mis-reanimation' => 0, + 'mis-outcome' => 0, + 'mis-outcome-discharged' => 0, + 'mis-outcome-deceased' => 0, + 'mis-outcome-transferred' => 0, + 'special-plan' => 0, + 'special-emergency' => 0, + 'special-observation' => 0, + 'special-reanimation' => 0, + 'special-outcome' => 0, + 'special-outcome-discharged' => 0, + 'special-outcome-deceased' => 0, + 'special-outcome-transferred' => 0, + ]; + + foreach ($baseStatuses as $baseStatus) { + $counts["mis-{$baseStatus}"] = $this->getPatientsCountByStatus( + $department, + $user, + "mis-{$baseStatus}", + $dateRange + ); + $counts["special-{$baseStatus}"] = $this->getPatientsCountByStatus( + $department, + $user, + "special-{$baseStatus}", + $dateRange + ); + } + + $counts['mis-outcome'] = ($counts['mis-outcome-discharged'] ?? 0) + ($counts['mis-outcome-deceased'] ?? 0); + $counts['special-outcome'] = ($counts['special-outcome-discharged'] ?? 0) + ($counts['special-outcome-deceased'] ?? 0); + + return $counts; + } + + /** + * Получить пациентскую выборку из submitted-снапшотов. + */ + public function getPatientsFromSnapshots( + Department $department, + string $status, + DateRange $dateRange, + int $branchId, + bool $onlyIds = false + ) { + [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); + $reportIds = $this->contextResolver + ->getReportsForDateRange($department->department_id, $dateRange) + ->pluck('report_id') + ->all(); + $recipientReportIds = $this->contextResolver->getRecipientReportIds($reportIds); + + $patientTypeMap = [ + 'plan' => 'plan', + 'emergency' => 'emergency', + 'current' => 'current', + 'recipient' => 'recipient', + 'outcome-discharged' => 'discharged', + 'outcome-transferred' => 'transferred', + 'outcome-deceased' => 'deceased', + 'observation' => 'observation', + ]; + + $patientType = $patientTypeMap[$baseStatus] ?? null; + + if ($patientType === 'observation') { + return $this->unifiedPatientService->getObservationPatients($department, $onlyIds, $sourceScope); + } + + if ($baseStatus === 'outcome') { + $discharged = $this->snapshotService->getPatientsFromSnapshots( + 'discharged', + $reportIds, + false, + false, + $recipientReportIds + ); + $deceased = $this->snapshotService->getPatientsFromSnapshots( + 'deceased', + $reportIds, + false, + false, + $recipientReportIds + ); + + $merged = UnifiedPatientData::unique($discharged->concat($deceased)) + ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') + ->values(); + + return $this->filterSnapshotPatientsByScope($merged, $sourceScope, $onlyIds); + } + + if (! $patientType) { + return collect(); + } + + if ($dateRange->isOneDay && in_array($baseStatus, ['plan', 'emergency'], true)) { + $patients = $this->snapshotService->getPatientsFromOneDayCurrentSnapshots( + $patientType, + $reportIds, + false, + $recipientReportIds + ); + + return $this->filterSnapshotPatientsByScope($patients, $sourceScope, $onlyIds); + } + + $patients = $this->snapshotService->getPatientsFromSnapshots( + $patientType, + $reportIds, + false, + in_array($baseStatus, ['plan', 'emergency'], true), + $recipientReportIds + ); + + return $this->filterSnapshotPatientsByScope($patients, $sourceScope, $onlyIds); + } + + /** + * Получить пациентов напрямую из live-реплики и manual-источников. + */ + private function getPatientsFromReplica( + Department $department, + User $user, + string $status, + DateRange $dateRange, + int $branchId, + bool $onlyIds = false, + ?bool $includeCurrent = null + ) { + [$baseStatus] = $this->parseScopedStatus($status); + $includeCurrent ??= in_array($baseStatus, ['plan', 'emergency', 'reanimation'], true); + + return $this->unifiedPatientService->getLivePatientsByStatus( + $department, + $user, + $status, + $dateRange, + $branchId, + $onlyIds, + $includeCurrent + ); + } + + /** + * Посчитать пациентов в снапшотах с той же семантикой scope, что и в legacy-сервисе. + */ + private function getPatientsCountFromSnapshots( + Department $department, + string $status, + DateRange $dateRange, + int $branchId + ): int { + [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); + $reportIds = $this->contextResolver + ->getReportsForDateRange($department->department_id, $dateRange) + ->pluck('report_id') + ->all(); + + if ($baseStatus === 'outcome') { + if ($sourceScope !== 'all') { + return $this->getPatientsFromSnapshots($department, $status, $dateRange, $branchId)->count(); + } + + return MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $reportIds) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->distinct('rf_medicalhistory_id') + ->count('rf_medicalhistory_id'); + } + + $patientTypeMap = [ + 'plan' => 'plan', + 'emergency' => 'emergency', + 'observation' => 'observation', + 'outcome-discharged' => 'discharged', + 'outcome-transferred' => 'transferred', + 'outcome-deceased' => 'deceased', + ]; + + $patientType = $patientTypeMap[$baseStatus] ?? null; + + if (! $patientType) { + return 0; + } + + if ($patientType === 'observation') { + return $this->unifiedPatientService->getObservationPatients($department, false, $sourceScope)->count(); + } + + if ($sourceScope !== 'all') { + return $this->getPatientsFromSnapshots($department, $status, $dateRange, $branchId)->count(); + } + + return MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $reportIds) + ->where('patient_type', $patientType) + ->distinct('rf_medicalhistory_id') + ->count('rf_medicalhistory_id'); + } + + /** + * Посчитать пациентов из реплики и manual-источников с legacy-правилами include-current. + */ + private function getPatientsCountFromReplica( + Department $department, + User $user, + string $status, + DateRange $dateRange, + int $branchId + ): int { + [$baseStatus] = $this->parseScopedStatus($status); + + return match ($status) { + 'plan', 'emergency', 'observation', 'reanimation', 'outcome', 'outcome-discharged', 'outcome-transferred', 'outcome-deceased', 'current', 'recipient' => $this->unifiedPatientService->getLivePatientCountByStatus( + $department, + $user, + $status, + $dateRange, + $branchId, + in_array($status, ['plan', 'emergency'], true) + ), + default => $this->unifiedPatientService->getLivePatientCountByStatus( + $department, + $user, + $status, + $dateRange, + $branchId, + in_array($baseStatus, ['plan', 'emergency'], true) + ), + }; + } + + /** + * Применить фильтрацию по MIS/manual scope к коллекции DTO из снапшотов. + */ + private function filterSnapshotPatientsByScope(Collection $patients, string $sourceScope, bool $onlyIds = false) + { + if ($sourceScope === 'all') { + return $onlyIds ? $patients->pluck('id') : $patients; + } + + $filtered = $patients->filter(function ($patient) use ($sourceScope) { + return match ($sourceScope) { + 'mis' => $patient->sourceType === 'mis', + 'special' => in_array($patient->sourceType, ['manual', 'special'], true), + default => true, + }; + })->values(); + + return $onlyIds ? $filtered->pluck('id') : $filtered; + } + + /** + * Разбить scoped-статус вроде "mis-plan" на базовый статус и scope источника. + * + * @return array{0: string, 1: string} + */ + private function parseScopedStatus(string $status): array + { + foreach (['mis', 'special'] as $scope) { + $prefix = "{$scope}-"; + + if (str_starts_with($status, $prefix)) { + return [substr($status, strlen($prefix)), $scope]; + } + } + + return [$status, 'all']; + } +} diff --git a/app/Infrastructure/Reports/Services/ReportReadContextResolver.php b/app/Infrastructure/Reports/Services/ReportReadContextResolver.php new file mode 100644 index 0000000..256e24c --- /dev/null +++ b/app/Infrastructure/Reports/Services/ReportReadContextResolver.php @@ -0,0 +1,116 @@ +where('rf_DepartmentID', $department->rf_mis_department_id) + ->value('StationarBranchID'); + } + + /** + * Определить, нужно ли читать submitted-снапшоты вместо live-данных. + */ + public function shouldUseSnapshots( + Department $department, + DateRange $dateRange, + bool $beforeCreate = false + ): bool { + if ($beforeCreate) { + return false; + } + + $report = $this->getReportForPeriod($department->department_id, $dateRange); + + return $report?->status === 'submitted'; + } + + /** + * Для самых изменчивых статусов врачи должны продолжать видеть live-данные за текущие сутки. + */ + public function shouldUseReplicaForLiveStatus(User $user, string $status, DateRange $dateRange): bool + { + if ($user->isHeadOfDepartment() || $user->isAdmin()) { + return false; + } + + return in_array($status, ['plan', 'emergency', 'recipient', 'current', 'reanimation'], true) + && $dateRange->isOneDay + && $dateRange->isEndDateToday(); + } + + /** + * Вернуть submitted-отчёты, относящиеся к выбранному отчётному окну. + * + * @return Collection + */ + public function getReportsForDateRange(int $departmentId, DateRange $dateRange): Collection + { + if ($dateRange->isOneDay) { + return Report::query() + ->where('rf_department_id', $departmentId) + ->exactPeriod($dateRange->startSql(), $dateRange->endSql()) + ->onlySubmitted() + ->orderBy('period_end', 'DESC') + ->get(); + } + + return Report::query() + ->where('rf_department_id', $departmentId) + ->withinPeriod($dateRange->startSql(), $dateRange->endSql()) + ->onlySubmitted() + ->orderBy('period_end', 'DESC') + ->get(); + } + + /** + * Recipient-снапшоты читаются из последнего отчёта в выбранном окне. + * + * @param array $reportIds + * @return array + */ + public function getRecipientReportIds(array $reportIds): array + { + if (empty($reportIds)) { + return []; + } + + return [reset($reportIds)]; + } + + /** + * Найти отчёт, который определяет видимость снапшотов для запрошенного периода. + */ + private function getReportForPeriod(int $departmentId, DateRange $dateRange): ?Report + { + $query = Report::query() + ->where('rf_department_id', $departmentId) + ->exactPeriod($dateRange->startSql(), $dateRange->endSql()) + ->orderByDesc('report_id'); + + if ($dateRange->isOneDay) { + return $query->first(); + } + + return $query->onlySubmitted()->first(); + } +} diff --git a/app/Infrastructure/Reports/Services/ReportStorageService.php b/app/Infrastructure/Reports/Services/ReportStorageService.php new file mode 100644 index 0000000..251fe5c --- /dev/null +++ b/app/Infrastructure/Reports/Services/ReportStorageService.php @@ -0,0 +1,166 @@ + $snapshot->departmentId, + 'rf_user_id' => $actor->id, + 'rf_lpudoctor_id' => $snapshot->userId, + 'sent_at' => $snapshot->sentAt?->format('Y-m-d H:i:s') ?? $snapshot->periodEnd->format('Y-m-d H:i:s'), + 'period_start' => $snapshot->periodStart->format('Y-m-d H:i:s'), + 'period_end' => $snapshot->periodEnd->format('Y-m-d H:i:s'), + 'created_at' => $snapshot->createdAt?->format('Y-m-d H:i:s') ?? $snapshot->periodEnd->format('Y-m-d H:i:s'), + 'status' => $snapshot->status, + ]; + + if ($snapshot->reportId) { + return Report::query()->updateOrCreate( + ['report_id' => $snapshot->reportId], + $reportData, + ); + } + + $report = Report::query()->create($reportData); + $department = Department::query()->find($snapshot->departmentId); + $beds = $department?->metrikaDefault->where('rf_metrika_item_id', MetrikaConfig::BEDS)->first(); + + if ($beds) { + MetrikaResult::query()->updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => MetrikaConfig::BEDS, + ], + ['value' => $beds->value] + ); + } + + return $report; + } + + public function saveMetrics(Report $report, ReportSnapshot $snapshot): void + { + foreach ($snapshot->normalizedMetrics() as $metricId => $value) { + MetrikaResult::query()->updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => $metricId, + ], + ['value' => $value] + ); + } + } + + public function saveMetric(Report $report, int $metricId, int|float $value): void + { + MetrikaResult::query()->updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => $metricId, + ], + ['value' => $value] + ); + } + + public function saveUnwantedEvents(Report $report, ReportSnapshot $snapshot): void + { + if ($snapshot->unwantedEvents === []) { + $report->unwantedEvents()->delete(); + $this->saveMetric($report, MetrikaConfig::UNWANTED_EVENTS, 0); + + return; + } + + $report->unwantedEvents() + ->whereNotIn('unwanted_event_id', array_values(array_filter(array_map( + static fn (array $event): ?int => isset($event['unwanted_event_id']) ? (int) $event['unwanted_event_id'] : null, + $snapshot->unwantedEvents + )))) + ->delete(); + + foreach ($snapshot->unwantedEvents as $event) { + if (! empty($event['unwanted_event_id'])) { + UnwantedEvent::query()->updateOrCreate( + ['unwanted_event_id' => (int) $event['unwanted_event_id']], + [ + 'rf_report_id' => $report->report_id, + 'comment' => (string) ($event['comment'] ?? ''), + 'title' => (string) ($event['title'] ?? ''), + 'is_visible' => (bool) ($event['is_visible'] ?? true), + ] + ); + + continue; + } + + UnwantedEvent::query()->create([ + 'rf_report_id' => $report->report_id, + 'comment' => (string) ($event['comment'] ?? ''), + 'title' => (string) ($event['title'] ?? ''), + 'is_visible' => (bool) ($event['is_visible'] ?? true), + ]); + } + + $this->saveMetric($report, MetrikaConfig::UNWANTED_EVENTS, count($snapshot->unwantedEvents)); + } + + public function saveObservationPatients(Report $report, ReportSnapshot $snapshot): void + { + if ($snapshot->observationPatients === []) { + ObservationPatient::query() + ->where('rf_department_id', $snapshot->departmentId) + ->where('rf_report_id', $report->report_id) + ->delete(); + $this->saveMetric($report, MetrikaConfig::OBSERVATION, 0); + + return; + } + + $observedKeys = []; + + foreach ($snapshot->observationPatients as $patient) { + $medicalHistoryId = isset($patient['medical_history_id']) ? (int) $patient['medical_history_id'] : null; + $departmentPatientId = isset($patient['department_patient_id']) ? (int) $patient['department_patient_id'] : null; + $observedKeys[] = $medicalHistoryId.'-'.$departmentPatientId; + + ObservationPatient::query()->updateOrCreate( + [ + 'rf_medicalhistory_id' => $medicalHistoryId, + 'rf_department_patient_id' => $departmentPatientId, + 'rf_department_id' => $snapshot->departmentId, + ], + [ + 'rf_report_id' => $report->report_id, + 'rf_mkab_id' => null, + 'comment' => $patient['comment'] ?? null, + ] + ); + } + + ObservationPatient::query() + ->where('rf_department_id', $snapshot->departmentId) + ->where('rf_report_id', $report->report_id) + ->get() + ->filter(fn (ObservationPatient $patient) => ! in_array( + ($patient->rf_medicalhistory_id ?? '').'-'.($patient->rf_department_patient_id ?? ''), + $observedKeys, + true + )) + ->each + ->delete(); + + $this->saveMetric($report, MetrikaConfig::OBSERVATION, count($snapshot->observationPatients)); + } +} diff --git a/app/Infrastructure/Reports/Services/SnapshotPersistenceService.php b/app/Infrastructure/Reports/Services/SnapshotPersistenceService.php new file mode 100644 index 0000000..a504b8d --- /dev/null +++ b/app/Infrastructure/Reports/Services/SnapshotPersistenceService.php @@ -0,0 +1,80 @@ + $metrics + */ + public function saveMetrics(Report $report, array $metrics): void + { + foreach ($metrics as $metrikaItemId => $value) { + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => $metrikaItemId, + ], + [ + 'value' => $value, + ] + ); + } + } + + public function createSnapshotsForType(Report $report, string $type, Collection $patients): void + { + foreach ($patients as $patient) { + if (! $patient instanceof UnifiedPatientData) { + continue; + } + + MedicalHistorySnapshot::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'patient_uid' => $patient->patientUid, + 'patient_type' => $type, + ], + [ + 'rf_report_id' => $report->report_id, + 'patient_type' => $type, + ...$patient->toSnapshotPayload($type), + ] + ); + } + } + + /** + * Удалить ранее построенные снапшоты перед полной перестройкой состояния отчёта. + */ + public function clearReportSnapshots(Report $report): void + { + MedicalHistorySnapshot::query() + ->where('rf_report_id', $report->report_id) + ->delete(); + } + + /** + * Сопоставить типы snapshot-пациентов с идентификаторами сохраняемых метрик. + * + * @return array + */ + public function snapshotMetricMap(): array + { + return [ + 'plan' => MetrikaConfig::PLAN, + 'emergency' => MetrikaConfig::EMERGENCY, + 'discharged' => MetrikaConfig::DISCHARGED, + 'transferred' => MetrikaConfig::TRANSFERRED, + ]; + } +} diff --git a/app/Infrastructure/Reports/Sources/LegacyAutoFillPatientSource.php b/app/Infrastructure/Reports/Sources/LegacyAutoFillPatientSource.php new file mode 100644 index 0000000..b9f9ef5 --- /dev/null +++ b/app/Infrastructure/Reports/Sources/LegacyAutoFillPatientSource.php @@ -0,0 +1,42 @@ +findOrFail($context->departmentId); + $user = User::query()->findOrFail($context->actorUserId ?? $context->userId); + $scopedUser = clone $user; + $scopedUser->rf_department_id = $department->department_id; + $scopedUser->setRelation('department', $department); + $dateRange = new DateRange( + startDate: Carbon::parse($context->periodStart->format('Y-m-d H:i:s'), 'Asia/Yakutsk'), + endDate: Carbon::parse($context->periodEnd->format('Y-m-d H:i:s'), 'Asia/Yakutsk'), + startDateRaw: $context->periodStart->format('Y-m-d H:i:s'), + endDateRaw: $context->periodEnd->format('Y-m-d H:i:s'), + isOneDay: $context->periodStart->diff($context->periodEnd)->days <= 1, + ); + + return new PatientCollection( + items: [], + metadata: [ + 'payload' => $this->legacyAdapter->buildAutoFillPayload($scopedUser, $department, $dateRange), + ], + ); + } +} diff --git a/app/Infrastructure/Reports/Sources/MisClinicalDataSource.php b/app/Infrastructure/Reports/Sources/MisClinicalDataSource.php new file mode 100644 index 0000000..d2d71d0 --- /dev/null +++ b/app/Infrastructure/Reports/Sources/MisClinicalDataSource.php @@ -0,0 +1,323 @@ +recipientPatientService->resolvePlanOrEmergencyMedicalHistoryIds( + $type, + $isHeadOrAdmin, + $branchId, + $dateRange, + $includeCurrent, + $fillableAuto + ); + + if (empty($medicalHistoryIds)) { + return $countOnly ? 0 : collect(); + } + + if ($countOnly) { + return count($medicalHistoryIds); + } + + if ($onlyIds) { + return collect($medicalHistoryIds); + } + + $recipientIds = $this->recipientPatientService->getRecipientMedicalHistoryIds( + $type, + $isHeadOrAdmin, + $branchId, + $dateRange + ); + + return $this->buildPatientCardsQuery($medicalHistoryIds, $branchId) + ->get() + ->map(function ($patient) use ($recipientIds) { + $patient->is_recipient_today = in_array($patient->MedicalHistoryID, $recipientIds, true); + + return $patient; + }); + } + + /** + * Загрузить всех MIS-пациентов, входящих в выборку отчётного отделения. + */ + public function getAllPatientsInDepartment( + bool $isHeadOrAdmin, + int $branchId, + DateRange $dateRange, + bool $countOnly = false, + bool $onlyIds = false, + bool $fillableAuto = false + ) { + $recipientIds = $this->recipientPatientService + ->buildRecipientQuery(null, $isHeadOrAdmin, $branchId, $dateRange, $fillableAuto) + ->pluck('rf_MedicalHistoryID') + ->toArray(); + + $currentIds = $fillableAuto + ? $this->currentPatientService->getHistoricalCurrentMedicalHistoryIds(null, $branchId, $dateRange) + : MisMigrationPatient::currentlyInTreatment($branchId)->pluck('rf_MedicalHistoryID')->toArray(); + + $allIds = array_unique(array_merge($recipientIds, $currentIds)); + + if (empty($allIds)) { + return $countOnly ? 0 : collect(); + } + + if ($countOnly) { + return count($allIds); + } + + if ($onlyIds) { + return collect($allIds); + } + + return $this->buildPatientCardsQuery($allIds, $branchId) + ->get() + ->map(function ($patient) use ($recipientIds) { + $patient->is_recipient_today = in_array($patient->MedicalHistoryID, $recipientIds, true); + + return $patient; + }); + } + + /** + * Загрузить пациентов с записями о реанимации, входящих в отчётную выборку. + */ + public function getReanimationPatients( + int $branchId, + DateRange $dateRange, + bool $onlyIds = false + ) { + $currentIds = $this->getAllPatientsInDepartment(true, $branchId, $dateRange, false, true, false)->all(); + $outcomeIds = $this->outcomePatientService->getOutcomePatients($branchId, $dateRange, 'all', true)->all(); + $reportCohortIds = array_values(array_unique(array_merge($currentIds, $outcomeIds))); + + if (empty($reportCohortIds)) { + return collect(); + } + + $reanimationByMedicalHistory = MisReanimation::query() + ->join('stt_migrationpatient as mp', 'mp.MigrationPatientID', '=', 'stt_reanimation.rf_MigrationPatientID') + ->where('mp.rf_StationarBranchID', $branchId) + ->where('mp.rf_MedicalHistoryID', '<>', 0) + ->whereIn('mp.rf_MedicalHistoryID', $reportCohortIds) + ->selectRaw(' + mp."rf_MedicalHistoryID" as medical_history_id, + MAX(stt_reanimation."DateIn") as reanimation_date_in, + BOOL_OR(COALESCE(stt_reanimation."isComplete", false)) as reanimation_is_complete + ') + ->groupBy('mp.rf_MedicalHistoryID') + ->get(); + + $medicalHistoryIds = $reanimationByMedicalHistory + ->pluck('medical_history_id') + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + if (empty($medicalHistoryIds)) { + return collect(); + } + + if ($onlyIds) { + return collect($medicalHistoryIds); + } + + $reanimationDateByMedicalHistory = $reanimationByMedicalHistory->pluck('reanimation_date_in', 'medical_history_id'); + $reanimationCompleteByMedicalHistory = $reanimationByMedicalHistory->pluck('reanimation_is_complete', 'medical_history_id'); + + return MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) + ->select($this->patientSelect()) + ->with($this->patientRelations($branchId)) + ->orderBy('DateRecipient', 'DESC') + ->get() + ->map(function ($patient) use ($reanimationDateByMedicalHistory, $reanimationCompleteByMedicalHistory) { + $reanimationDateIn = $reanimationDateByMedicalHistory->get($patient->MedicalHistoryID); + if ($reanimationDateIn) { + $patient->DateRecipient = $reanimationDateIn; + } + $patient->reanimation_is_complete = (bool) $reanimationCompleteByMedicalHistory->get($patient->MedicalHistoryID, false); + + return $patient; + }); + } + + /** + * Загрузить или посчитать хирургические операции в отчётном периоде. + */ + public function getSurgicalPatients( + string $type, + int $branchId, + DateRange $dateRange, + bool $countOnly = false + ) { + $query = MisSurgicalOperation::where('rf_StationarBranchID', $branchId) + ->completed() + ->where('Date', '>=', $dateRange->startSql()) + ->where('Date', '<=', $dateRange->endSql()); + + if ($type === 'plan') { + $query->where('rf_TypeSurgOperationInTimeID', 6); + } else { + $query->whereIn('rf_TypeSurgOperationInTimeID', [4, 5]); + } + + return $countOnly ? $query->count() : $query->get(); + } + + /** + * Посчитать плановых или экстренных пациентов, когда нужно включать текущих. + */ + public function getPatientsCountWithCurrent( + ?string $type, + bool $isHeadOrAdmin, + int $branchId, + DateRange $dateRange + ): int { + return $this->getPlanOrEmergencyPatients( + $type, + $isHeadOrAdmin, + $branchId, + $dateRange, + true, + false, + true + ); + } + + /** + * Общий MIS-запрос карточек пациентов с операциями и диагнозами. + */ + public function buildPatientCardsQuery(array $medicalHistoryIds, int $branchId) + { + return MisMedicalHistory::query() + ->whereIn('MedicalHistoryID', $medicalHistoryIds) + ->select($this->patientSelect()) + ->with($this->patientRelations($branchId)) + ->orderBy('DateRecipient', 'DESC'); + } + + /** + * Базовый select, используемый во всех выборках пациентов из МИС. + */ + private function patientSelect(): array + { + return [ + 'MedicalHistoryID', + 'FAMILY', + 'Name', + 'OT', + 'BD', + 'DateRecipient', + 'DateExtract', + 'DateDeath', + 'rf_EmerSignID', + 'rf_kl_VisitResultID', + ]; + } + + /** + * Общие eager-loaded связи для MIS-карточек пациентов. + */ + private function patientRelations(int $branchId): array + { + return [ + 'surgicalOperations' => function ($q) { + $q->select([ + 'SurgicalOperationID', + 'rf_MedicalHistoryID', + 'rf_kl_ServiceMedicalID', + 'Date', + ])->with(['serviceMedical' => function ($serviceQuery) { + $serviceQuery->select([ + 'ServiceMedicalID', + 'ServiceMedicalCode', + 'ServiceMedicalName', + ]); + }]); + }, + 'outcomeMigration' => function ($q) { + $q->select([ + 'stt_migrationpatient.MigrationPatientID', + 'stt_migrationpatient.rf_MedicalHistoryID', + 'stt_migrationpatient.DateOut', + 'stt_migrationpatient.rf_DiagnosID', + ])->with(['mainDiagnosis' => function ($diagnosisQuery) { + $diagnosisQuery->select([ + 'DiagnosID', + 'rf_MKBID', + ])->with(['mkb' => function ($mkbQuery) { + $mkbQuery->select([ + 'MKBID', + 'DS', + 'NAME', + ]); + }]); + }]); + }, + 'migrations' => function ($q) use ($branchId) { + $q->where('rf_StationarBranchID', $branchId) + ->select([ + 'MigrationPatientID', + 'rf_MedicalHistoryID', + 'rf_DiagnosID', + 'DateIngoing', + 'rf_StationarBranchID', + ]) + ->orderByDesc('DateIngoing') + ->with(['mainDiagnosis' => function ($diagnosisQuery) { + $diagnosisQuery->select([ + 'DiagnosID', + 'rf_MKBID', + 'rf_MigrationPatientID', + ])->with(['mkb' => function ($mkbQuery) { + $mkbQuery->select([ + 'MKBID', + 'DS', + 'NAME', + ]); + }]); + }]); + }, + ]; + } +} diff --git a/app/Infrastructure/Reports/Sources/MisPatientSource.php b/app/Infrastructure/Reports/Sources/MisPatientSource.php new file mode 100644 index 0000000..97680f0 --- /dev/null +++ b/app/Infrastructure/Reports/Sources/MisPatientSource.php @@ -0,0 +1,144 @@ +getPatients($user, $status, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots) + ->map(fn ($patient) => UnifiedPatientData::fromMisMedicalHistory( + $patient, + (bool) ($patient->is_recipient_today ?? false), + )) + ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') + ->values(); + } + + /** + * Загрузить сырые MIS-модели пациентов для запрошенного статуса отчёта. + */ + public function getPatients( + User $user, + string $status, + DateRange $dateRange, + int $branchId, + ?bool $includeCurrent = null, + bool $fillableAuto = false, + bool $forSnapshots = false + ): Collection { + $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); + $includeCurrent = $includeCurrent ?? in_array($status, ['plan', 'emergency'], true); + + return match ($status) { + 'plan', 'emergency' => $this->patientService->getPlanOrEmergencyPatients( + $status, + $isHeadOrAdmin, + $branchId, + $dateRange, + false, + false, + $includeCurrent, + $fillableAuto + ), + 'current' => $this->patientService->getAllPatientsInDepartment( + $isHeadOrAdmin, + $branchId, + $dateRange, + false, + false, + $fillableAuto + ), + 'recipient' => $this->patientService->getPlanOrEmergencyPatients( + null, + $isHeadOrAdmin, + $branchId, + $dateRange, + false, + false, + false, + $fillableAuto + ), + 'outcome' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'without-transferred'), + 'outcome-discharged' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'discharged'), + 'outcome-transferred' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'transferred'), + 'outcome-deceased' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'deceased'), + 'reanimation' => $this->patientService->getReanimationPatients($branchId, $dateRange), + default => collect(), + }; + } + + /** + * Посчитать MIS-пациентов без материализации DTO. + */ + public function getCount( + User $user, + string $status, + DateRange $dateRange, + int $branchId, + ?bool $includeCurrent = null, + bool $fillableAuto = false + ): int { + $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); + $includeCurrent = $includeCurrent ?? in_array($status, ['plan', 'emergency'], true); + + return match ($status) { + 'plan', 'emergency' => $includeCurrent + ? $this->patientService->getPatientsCountWithCurrent($status, $isHeadOrAdmin, $branchId, $dateRange) + : $this->patientService->getPlanOrEmergencyPatients( + $status, + $isHeadOrAdmin, + $branchId, + $dateRange, + true, + false, + false, + $fillableAuto + ), + 'current' => $this->patientService->getAllPatientsInDepartment( + $isHeadOrAdmin, + $branchId, + $dateRange, + true, + false, + $fillableAuto + ), + 'recipient' => $this->patientService->getPlanOrEmergencyPatients( + null, + $isHeadOrAdmin, + $branchId, + $dateRange, + true, + false, + false, + $fillableAuto + ), + 'outcome' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'without-transferred', true)->count(), + 'outcome-discharged' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'discharged', true)->count(), + 'outcome-transferred' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'transferred', true)->count(), + 'outcome-deceased' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'deceased', true)->count(), + 'reanimation' => $this->patientService->getReanimationPatients($branchId, $dateRange, true)->count(), + default => 0, + }; + } +} diff --git a/app/Infrastructure/Reports/Sources/SnapshotPatientSource.php b/app/Infrastructure/Reports/Sources/SnapshotPatientSource.php new file mode 100644 index 0000000..8e83361 --- /dev/null +++ b/app/Infrastructure/Reports/Sources/SnapshotPatientSource.php @@ -0,0 +1,218 @@ + $this->getCountFromSnapshots('plan', $reportIds), + 'emergency' => $this->getCountFromSnapshots('emergency', $reportIds), + 'outcome' => $this->getCountFromSnapshots('outcome', $reportIds), + 'deceased' => $this->getCountFromSnapshots('deceased', $reportIds), + 'discharged' => $this->getCountFromSnapshots('discharged', $reportIds), + 'transferred' => $this->getCountFromSnapshots('transferred', $reportIds), + 'recipient' => $this->getCountFromSnapshots('recipient', $reportIds), + ]; + } + + public function getPatientsFromSnapshots( + string $type, + array $reportIds, + bool $onlyIds = false, + bool $markRecipients = false, + ?array $recipientReportIds = null + ): Collection { + $snapshots = MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $reportIds) + ->where('patient_type', $type) + ->get() + ->unique(fn (MedicalHistorySnapshot $snapshot) => $this->snapshotIdentity($snapshot)) + ->values(); + + if ($snapshots->isEmpty()) { + return collect(); + } + + $recipientIds = []; + if ($markRecipients) { + $recipientReportIds ??= $reportIds; + $recipientIds = MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $recipientReportIds) + ->where('patient_type', 'recipient') + ->get() + ->map(fn (MedicalHistorySnapshot $snapshot) => UnifiedPatientData::fromSnapshot($snapshot)->id) + ->unique() + ->values() + ->all(); + } + + $operationsByHistoryId = $this->getOperationsByMedicalHistoryId($snapshots); + $operationsByDepartmentPatientId = $this->getOperationsByDepartmentPatientId($snapshots); + + $patients = $snapshots->map(function (MedicalHistorySnapshot $snapshot) use ($recipientIds, $operationsByHistoryId, $operationsByDepartmentPatientId) { + $patientId = $snapshot->rf_department_patient_id + ? "manual:{$snapshot->rf_department_patient_id}" + : ($snapshot->patient_uid ?: "mis:{$snapshot->rf_medicalhistory_id}"); + + $misOperations = $snapshot->rf_medicalhistory_id + ? ($operationsByHistoryId[$snapshot->rf_medicalhistory_id] ?? []) + : []; + $manualOperations = $snapshot->rf_department_patient_id + ? ($operationsByDepartmentPatientId[$snapshot->rf_department_patient_id] ?? []) + : []; + $operations = collect($misOperations) + ->merge($manualOperations) + ->unique(fn (array $operation) => ($operation['code'] ?? '').'|'.($operation['name'] ?? '')) + ->values() + ->all(); + + return UnifiedPatientData::fromSnapshot( + $snapshot, + in_array($patientId, $recipientIds, true), + $operations + ); + })->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '')->values(); + + return $onlyIds ? $patients->pluck('id') : $patients; + } + + public function getPatientsFromOneDayCurrentSnapshots( + string $type, + array $reportIds, + bool $onlyIds = false, + ?array $recipientReportIds = null + ): Collection { + $snapshots = MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $reportIds) + ->where('patient_type', 'current') + ->get(); + + if ($snapshots->isEmpty()) { + return $this->getPatientsFromSnapshots( + $type, + $reportIds, + $onlyIds, + true, + $recipientReportIds + ); + } + + $recipientReportIds ??= $reportIds; + $recipientIds = MedicalHistorySnapshot::query() + ->whereIn('rf_report_id', $recipientReportIds) + ->where('patient_type', 'recipient') + ->get() + ->map(fn (MedicalHistorySnapshot $snapshot) => UnifiedPatientData::fromSnapshot($snapshot)->id) + ->unique() + ->values() + ->all(); + + $operationsByHistoryId = $this->getOperationsByMedicalHistoryId($snapshots); + $operationsByDepartmentPatientId = $this->getOperationsByDepartmentPatientId($snapshots); + + $patients = $snapshots + ->filter(fn (MedicalHistorySnapshot $snapshot) => $snapshot->patient_kind === $type) + ->unique(fn (MedicalHistorySnapshot $snapshot) => $this->snapshotIdentity($snapshot)) + ->map(function (MedicalHistorySnapshot $snapshot) use ($recipientIds, $operationsByHistoryId, $operationsByDepartmentPatientId) { + $misOperations = $snapshot->rf_medicalhistory_id + ? ($operationsByHistoryId[$snapshot->rf_medicalhistory_id] ?? []) + : []; + $manualOperations = $snapshot->rf_department_patient_id + ? ($operationsByDepartmentPatientId[$snapshot->rf_department_patient_id] ?? []) + : []; + $operations = collect($misOperations) + ->merge($manualOperations) + ->unique(fn (array $operation) => ($operation['code'] ?? '').'|'.($operation['name'] ?? '')) + ->values() + ->all(); + + $patient = UnifiedPatientData::fromSnapshot( + $snapshot, + false, + $operations + ); + $patient->isRecipientToday = in_array($patient->id, $recipientIds, true); + + return $patient; + }) + ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') + ->values(); + + return $onlyIds ? $patients->pluck('id') : $patients; + } + + private function getCountFromSnapshots(string $type, array $reportIds): int + { + $query = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds); + + if ($type === 'outcome') { + $query->whereIn('patient_type', ['discharged', 'deceased']); + } else { + $query->where('patient_type', $type); + } + + return $query->get() + ->map(fn (MedicalHistorySnapshot $snapshot) => $this->snapshotIdentity($snapshot)) + ->unique() + ->count(); + } + + private function snapshotIdentity(MedicalHistorySnapshot $snapshot): string + { + return $snapshot->patient_uid + ?: ($snapshot->rf_medicalhistory_id + ? "mis:{$snapshot->rf_medicalhistory_id}" + : "snapshot:{$snapshot->medical_history_snapshot_id}"); + } + + private function getOperationsByMedicalHistoryId(Collection $snapshots): array + { + $historyIds = $snapshots->pluck('rf_medicalhistory_id')->filter()->unique()->values(); + + if ($historyIds->isEmpty()) { + return []; + } + + return MisMedicalHistory::query() + ->whereIn('MedicalHistoryID', $historyIds) + ->with(['surgicalOperations.serviceMedical']) + ->get() + ->mapWithKeys(function (MisMedicalHistory $history) { + return [ + $history->MedicalHistoryID => $history->surgicalOperations->map(fn ($operation) => [ + 'code' => $operation->serviceMedical?->ServiceMedicalCode, + 'name' => $operation->serviceMedical?->ServiceMedicalName, + ])->values()->all(), + ]; + }) + ->all(); + } + + private function getOperationsByDepartmentPatientId(Collection $snapshots): array + { + $departmentPatientIds = $snapshots->pluck('rf_department_patient_id')->filter()->unique()->values(); + + if ($departmentPatientIds->isEmpty()) { + return []; + } + + return DepartmentPatientOperation::query() + ->whereIn('rf_department_patient_id', $departmentPatientIds) + ->with('serviceMedical') + ->get() + ->groupBy('rf_department_patient_id') + ->map(fn (Collection $operations) => $operations->map(fn (DepartmentPatientOperation $operation) => [ + 'code' => $operation->serviceMedical?->ServiceMedicalCode ?? $operation->service_code, + 'name' => $operation->serviceMedical?->ServiceMedicalName ?? $operation->service_name, + ])->values()->all()) + ->all(); + } +} diff --git a/app/Infrastructure/Reports/Sources/SpecialPatientSource.php b/app/Infrastructure/Reports/Sources/SpecialPatientSource.php new file mode 100644 index 0000000..e21b073 --- /dev/null +++ b/app/Infrastructure/Reports/Sources/SpecialPatientSource.php @@ -0,0 +1,207 @@ +|null $sourceTypes + */ + public function getDtos( + Department $department, + string $status, + DateRange $dateRange, + ?array $sourceTypes = ['manual', 'special'], + bool $forSnapshots = false + ): Collection { + return $this->mapManualPatients( + $this->getPatients($department, $status, $dateRange, $sourceTypes, ! $forSnapshots), + $dateRange + ); + } + + /** + * Загрузить сырые manual/special записи пациентов для запрошенного статуса отчёта. + * + * @param array|null $sourceTypes + */ + public function getPatients( + Department $department, + string $status, + DateRange $dateRange, + ?array $sourceTypes = ['manual', 'special'], + bool $withOperations = true + ): Collection { + $query = $this->buildManualPatientsQuery($department, $dateRange, $sourceTypes, $withOperations); + + return match ($status) { + 'plan', 'emergency' => $query->current()->where('patient_kind', $status)->get(), + 'current' => $query->current()->get(), + 'recipient' => $query->whereBetween('admitted_at', [$dateRange->startSql(), $dateRange->endSql()])->get(), + 'outcome' => $query + ->whereNotNull('outcome_type') + ->whereIn('outcome_type', ['discharged', 'deceased']) + ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->get(), + 'outcome-discharged', 'outcome-transferred', 'outcome-deceased' => $query + ->where('outcome_type', str_replace('outcome-', '', $status)) + ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->get(), + 'reanimation' => collect(), + default => collect(), + }; + } + + /** + * Посчитать manual/special пациентов для статуса отчёта. + * + * @param array|null $sourceTypes + */ + public function getCount( + Department $department, + string $status, + DateRange $dateRange, + ?array $sourceTypes = ['manual', 'special'] + ): int { + $query = $this->buildManualPatientsQuery($department, $dateRange, $sourceTypes, false); + + return match ($status) { + 'plan', 'emergency' => $query->current()->where('patient_kind', $status)->count(), + 'current' => $query->current()->count(), + 'recipient' => $query->whereBetween('admitted_at', [$dateRange->startSql(), $dateRange->endSql()])->count(), + 'outcome' => $query + ->whereNotNull('outcome_type') + ->whereIn('outcome_type', ['discharged', 'deceased']) + ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->count(), + 'outcome-discharged', 'outcome-transferred', 'outcome-deceased' => $query + ->where('outcome_type', str_replace('outcome-', '', $status)) + ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->count(), + default => 0, + }; + } + + /** + * Загрузить manual-пациентов, уже связанных с MIS-картами в пределах отчётного окна. + */ + public function getLinkedManualPatientsForPeriod(Department $department, DateRange $dateRange): Collection + { + $reportIds = $this->getReportIdsForDepartmentPeriod($department, $dateRange); + + return DepartmentPatient::query() + ->where(function ($builder) use ($department, $reportIds) { + if (! empty($reportIds)) { + $builder->whereIn('rf_report_id', $reportIds); + } + + $builder->orWhere(function ($legacyQuery) use ($department) { + $legacyQuery->whereNull('rf_report_id') + ->where('rf_department_id', $department->department_id); + }); + }) + ->whereIn('source_type', ['manual', 'special']) + ->whereNotNull('rf_medicalhistory_id') + ->get() + ->keyBy('rf_medicalhistory_id'); + } + + /** + * @param array|null $sourceTypes + */ + private function buildManualPatientsQuery( + Department $department, + DateRange $dateRange, + ?array $sourceTypes, + bool $withOperations + ) { + $reportIds = $this->getReportIdsForDepartmentPeriod($department, $dateRange); + + $query = DepartmentPatient::query() + ->where(function ($builder) use ($department, $reportIds) { + if (! empty($reportIds)) { + $builder->whereIn('rf_report_id', $reportIds); + } + + $builder->orWhere(function ($legacyQuery) use ($department) { + $legacyQuery->whereNull('rf_report_id') + ->where('rf_department_id', $department->department_id); + }); + }); + + if ($withOperations) { + $query->with(['operations.serviceMedical']); + } + + if ($sourceTypes !== null) { + $query->whereIn('source_type', $sourceTypes); + } + + return $query; + } + + private function getReportIdsForDepartmentPeriod(Department $department, DateRange $dateRange): array + { + return Report::query() + ->where('rf_department_id', $department->department_id) + ->when( + $dateRange->isOneDay, + fn ($query) => $query->exactPeriod($dateRange->startSql(), $dateRange->endSql()), + fn ($query) => $query->withinPeriod($dateRange->startSql(), $dateRange->endSql()), + ) + ->pluck('report_id') + ->all(); + } + + private function mapManualPatients(Collection $manualPatients, DateRange $dateRange): Collection + { + return $manualPatients + ->map(function (DepartmentPatient $patient) use ($dateRange) { + $operationsRelation = $patient->relationLoaded('operations') + ? $patient->operations + : collect(); + + $operations = $operationsRelation->map(fn ($operation) => [ + 'id' => $operation->department_patient_operation_id, + 'code' => $operation->serviceMedical?->ServiceMedicalCode ?? $operation->service_code, + 'name' => $operation->serviceMedical?->ServiceMedicalName ?? $operation->service_name, + 'startAt' => $operation->started_at?->toIso8601String(), + 'endAt' => $operation->ended_at?->toIso8601String(), + ])->filter(fn ($operation) => $operation['code'] || $operation['name'])->values()->all(); + + return UnifiedPatientData::fromDepartmentPatient( + $patient, + $patient->admitted_at?->betweenIncluded($dateRange->startDate, $dateRange->endDate) ?? false, + $operations, + $this->resolveObservationComment(null, $patient->department_patient_id) + ); + }) + ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') + ->values(); + } + + private function resolveObservationComment(?int $medicalHistoryId, ?int $departmentPatientId): ?string + { + $query = ObservationPatient::query(); + + if ($departmentPatientId) { + $query->where('rf_department_patient_id', $departmentPatientId); + } elseif ($medicalHistoryId) { + $query->where('rf_medicalhistory_id', $medicalHistoryId); + } else { + return null; + } + + return $query->pluck('comment')->filter()->implode('; ') ?: null; + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php index cdf6494..8830862 100644 --- a/app/Models/Report.php +++ b/app/Models/Report.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Domain\Reports\ValueObjects\MetrikaConfig; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -11,7 +12,7 @@ class Report extends Model /** * ID метрики для среднего койко-дня */ - const METRIC_BED_DAYS_ID = 18; + const METRIC_BED_DAYS_ID = MetrikaConfig::AVERAGE_BED_DAYS; protected $primaryKey = 'report_id'; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 56f4373..c6a8767 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,16 @@ namespace App\Providers; +use App\Application\Reports\CompareLegacyAndNewReportUseCase; +use App\Application\Reports\GenerateReportUseCase; +use App\Application\Reports\ReportInputFactory; +use App\Domain\Reports\Contracts\AuditLogger; +use App\Domain\Reports\Contracts\PatientSource; +use App\Domain\Reports\Contracts\ReportRepository; +use App\Infrastructure\Reports\Adapters\LegacyReportServiceAdapter; +use App\Infrastructure\Reports\Logging\ReportsAuditLogger; +use App\Infrastructure\Reports\Repositories\EloquentReportRepository; +use App\Infrastructure\Reports\Sources\LegacyAutoFillPatientSource; use App\Services\Cache\CacheInvalidator; use App\Services\Cache\CacheKeyBuilder; use Illuminate\Support\ServiceProvider; @@ -18,6 +28,26 @@ class AppServiceProvider extends ServiceProvider }); $this->app->singleton(CacheInvalidator::class); + $this->app->singleton(ReportRepository::class, EloquentReportRepository::class); + $this->app->singleton(AuditLogger::class, ReportsAuditLogger::class); + $this->app->singleton(PatientSource::class, LegacyAutoFillPatientSource::class); + $this->app->singleton(CompareLegacyAndNewReportUseCase::class, function ($app) { + return new CompareLegacyAndNewReportUseCase( + $app->make(ReportRepository::class), + $app->make(LegacyReportServiceAdapter::class), + ); + }); + $this->app->singleton(ReportInputFactory::class); + $this->app->singleton(GenerateReportUseCase::class, function ($app) { + return new GenerateReportUseCase( + reportRepository: $app->make(ReportRepository::class), + auditLogger: $app->make(AuditLogger::class), + comparator: $app->make(CompareLegacyAndNewReportUseCase::class), + patientSource: $app->make(PatientSource::class), + calculators: [], + compareBeforeCutover: (bool) config('reports.use_new_arch.compare_before_cutover', true), + ); + }); } /** diff --git a/app/Services/AutoReportService.php b/app/Services/AutoReportService.php index 699faae..dd39cb5 100644 --- a/app/Services/AutoReportService.php +++ b/app/Services/AutoReportService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Application\Reports\ReportSavePathService; use App\Models\Department; use App\Models\MedicalHistorySnapshot; use App\Models\MetrikaResult; @@ -17,7 +18,8 @@ class AutoReportService { public function __construct( protected ReportService $reportService, - protected DateRangeService $dateRangeService + protected DateRangeService $dateRangeService, + protected ReportSavePathService $reportSavePathService, ) {} /** @@ -81,25 +83,13 @@ class AutoReportService $this->deleteExistingReport($existingReport); } - // Получаем данные для отчета - $reportData = $this->prepareReportData($scopedUser, $department, $dateRange); - - // Создаем отчет - DB::transaction(function () use ($scopedUser, $reportData) { - $this->reportService->storeReport($reportData, $scopedUser, true); + DB::transaction(function () use ($scopedUser, $department, $dateRange) { + $this->reportSavePathService->saveAutoFill($scopedUser, $department, $dateRange); }); return true; } - /** - * Подготовить данные для отчета - */ - private function prepareReportData(User $user, Department $department, DateRange $dateRange): array - { - return $this->reportService->buildAutoFillReportPayload($user, $department, $dateRange); - } - private function scopeUserToDepartment(User $user, Department $department): User { $scopedUser = clone $user; diff --git a/app/Services/PatientService.php b/app/Services/PatientService.php index 10ab8da..e92f5b8 100644 --- a/app/Services/PatientService.php +++ b/app/Services/PatientService.php @@ -2,21 +2,25 @@ namespace App\Services; +use App\Infrastructure\Reports\Sources\MisClinicalDataSource; use App\Models\MisMedicalHistory; -use App\Models\MisMigrationPatient; -use App\Models\MisReanimation; -use App\Models\MisSurgicalOperation; class PatientService { public function __construct( protected OutcomePatientService $outcomePatientService, protected RecipientPatientService $recipientPatientService, - protected CurrentPatientService $currentPatientService - ) {} + protected CurrentPatientService $currentPatientService, + ?MisClinicalDataSource $misClinicalDataSource = null, + ) { + $this->misClinicalDataSource = $misClinicalDataSource ?? app(MisClinicalDataSource::class); + } + + protected MisClinicalDataSource $misClinicalDataSource; /** - * Получить плановых или экстренных пациентов + * Получить плановых или экстренных пациентов из МИС, + * при необходимости объединяя их с уже текущими в отделении. */ public function getPlanOrEmergencyPatients( ?string $type, @@ -28,47 +32,21 @@ class PatientService bool $includeCurrent = false, bool $fillableAuto = false ) { - $medicalHistoryIds = $this->recipientPatientService->resolvePlanOrEmergencyMedicalHistoryIds( + return $this->misClinicalDataSource->getPlanOrEmergencyPatients( $type, $isHeadOrAdmin, $branchId, $dateRange, + $countOnly, + $onlyIds, $includeCurrent, $fillableAuto ); - - if (empty($medicalHistoryIds)) { - return $countOnly ? 0 : collect(); - } - - if ($countOnly) { - return count($medicalHistoryIds); - } - - if ($onlyIds) { - return collect($medicalHistoryIds); - } - - $recipientIds = $this->recipientPatientService->getRecipientMedicalHistoryIds( - $type, - $isHeadOrAdmin, - $branchId, - $dateRange - ); - - $res = $this->buildPatientCardsQuery($medicalHistoryIds, $branchId); - - return $res->get() - ->map(function ($patient) use ($recipientIds) { - $patient->is_recipient_today = in_array($patient->MedicalHistoryID, $recipientIds, true); - - return $patient; - }); - } /** - * Получить всех пациентов в отделении (поступившие сегодня + уже лечащиеся) + * Получить всех MIS-пациентов в отделении: + * поступившие за период плюс уже текущие в отделении. */ public function getAllPatientsInDepartment( bool $isHeadOrAdmin, @@ -78,115 +56,14 @@ class PatientService bool $onlyIds = false, bool $fillableAuto = false ) { - $recipientIds = $this->recipientPatientService->buildRecipientQuery(null, $isHeadOrAdmin, $branchId, $dateRange, $fillableAuto) - ->pluck('rf_MedicalHistoryID') - ->toArray(); - - if ($fillableAuto) { - $currentIds = $this->currentPatientService->getHistoricalCurrentMedicalHistoryIds(null, $branchId, $dateRange); - } else { - $currentIds = MisMigrationPatient::currentlyInTreatment($branchId) - ->pluck('rf_MedicalHistoryID') - ->toArray(); - } - - $allIds = array_unique(array_merge($recipientIds, $currentIds)); - - if (empty($allIds)) { - if ($countOnly) { - return 0; - } - - return collect(); - } - - if ($countOnly) { - return count($allIds); - } - - if ($onlyIds) { - return collect($allIds); - } - - $res = MisMedicalHistory::whereIn('MedicalHistoryID', $allIds) - ->select([ - 'MedicalHistoryID', - 'FAMILY', - 'Name', - 'OT', - 'BD', - 'DateRecipient', - 'DateExtract', - 'rf_EmerSignID', - 'rf_kl_VisitResultID', - ]) - ->with([ - 'surgicalOperations' => function ($q) { - $q->select([ - 'SurgicalOperationID', - 'rf_MedicalHistoryID', - 'rf_kl_ServiceMedicalID', - 'Date', - ])->with(['serviceMedical' => function ($serviceQuery) { - $serviceQuery->select([ - 'ServiceMedicalID', - 'ServiceMedicalCode', - 'ServiceMedicalName', - ]); - }]); - }, - 'outcomeMigration' => function ($q) { - $q->select([ - 'stt_migrationpatient.MigrationPatientID', - 'stt_migrationpatient.rf_MedicalHistoryID', - 'stt_migrationpatient.DateOut', - 'stt_migrationpatient.rf_DiagnosID', - ])->with(['mainDiagnosis' => function ($diagnosisQuery) { - $diagnosisQuery->select([ - 'DiagnosID', - 'rf_MKBID', - ])->with(['mkb' => function ($mkbQuery) { - $mkbQuery->select([ - 'MKBID', - 'DS', - 'NAME', - ]); - }]); - }]); - }, - 'migrations' => function ($q) use ($branchId) { - $q->where('rf_StationarBranchID', $branchId) - ->select([ - 'MigrationPatientID', - 'rf_MedicalHistoryID', - 'rf_DiagnosID', - 'DateIngoing', - 'rf_StationarBranchID', - ]) - ->orderByDesc('DateIngoing') - ->with(['mainDiagnosis' => function ($diagnosisQuery) { - $diagnosisQuery->select([ - 'DiagnosID', - 'rf_MKBID', - 'rf_MigrationPatientID', - ])->with(['mkb' => function ($mkbQuery) { - $mkbQuery->select([ - 'MKBID', - 'DS', - 'NAME', - ]); - }]); - }]); - }, - ]) - ->orderBy('DateRecipient', 'DESC'); - - return $res->get() - ->map(function ($patient) use ($recipientIds) { - $patient->is_recipient_today = in_array($patient->MedicalHistoryID, $recipientIds); - - return $patient; - }); + return $this->misClinicalDataSource->getAllPatientsInDepartment( + $isHeadOrAdmin, + $branchId, + $dateRange, + $countOnly, + $onlyIds, + $fillableAuto + ); } /** @@ -226,7 +103,7 @@ class PatientService } /** - * Получить выбывших пациентов + * Получить выбывших MIS-пациентов по типу исхода. */ public function getOutcomePatients( int $branchId, @@ -238,129 +115,18 @@ class PatientService } /** - * Получить пациентов с записями в реанимации по отделению + * Получить пациентов с записями в реанимации по отделению. */ public function getReanimationPatients( int $branchId, DateRange $dateRange, bool $onlyIds = false ) { - $currentIds = $this->getAllPatientsInDepartment( - true, - $branchId, - $dateRange, - false, - true, - false - )->all(); - - $outcomeIds = $this->getOutcomePatients( - $branchId, - $dateRange, - 'all', - true - )->all(); - - $reportCohortIds = array_values(array_unique(array_merge($currentIds, $outcomeIds))); - - if (empty($reportCohortIds)) { - return collect(); - } - - $reanimationByMedicalHistory = MisReanimation::query() - ->join('stt_migrationpatient as mp', 'mp.MigrationPatientID', '=', 'stt_reanimation.rf_MigrationPatientID') - ->where('mp.rf_StationarBranchID', $branchId) - ->where('mp.rf_MedicalHistoryID', '<>', 0) - ->whereIn('mp.rf_MedicalHistoryID', $reportCohortIds) - ->selectRaw(' - mp."rf_MedicalHistoryID" as medical_history_id, - MAX(stt_reanimation."DateIn") as reanimation_date_in, - BOOL_OR(COALESCE(stt_reanimation."isComplete", false)) as reanimation_is_complete - ') - ->groupBy('mp.rf_MedicalHistoryID') - ->get(); - - $medicalHistoryIds = $reanimationByMedicalHistory - ->pluck('medical_history_id') - ->map(fn ($id) => (int) $id) - ->values() - ->all(); - - $reanimationDateByMedicalHistory = $reanimationByMedicalHistory - ->pluck('reanimation_date_in', 'medical_history_id'); - $reanimationCompleteByMedicalHistory = $reanimationByMedicalHistory - ->pluck('reanimation_is_complete', 'medical_history_id'); - - if (empty($medicalHistoryIds)) { - return collect(); - } - - if ($onlyIds) { - return collect($medicalHistoryIds); - } - - return MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - ->select([ - 'MedicalHistoryID', - 'FAMILY', - 'Name', - 'OT', - 'BD', - 'DateRecipient', - 'DateExtract', - 'rf_EmerSignID', - 'rf_kl_VisitResultID', - ]) - ->with([ - 'surgicalOperations' => function ($q) { - $q->select([ - 'SurgicalOperationID', - 'rf_MedicalHistoryID', - 'rf_kl_ServiceMedicalID', - 'Date', - ])->with(['serviceMedical' => function ($serviceQuery) { - $serviceQuery->select([ - 'ServiceMedicalID', - 'ServiceMedicalCode', - 'ServiceMedicalName', - ]); - }]); - }, - 'outcomeMigration' => function ($q) { - $q->select([ - 'stt_migrationpatient.MigrationPatientID', - 'stt_migrationpatient.rf_MedicalHistoryID', - 'stt_migrationpatient.DateOut', - 'stt_migrationpatient.rf_DiagnosID', - ])->with(['mainDiagnosis' => function ($diagnosisQuery) { - $diagnosisQuery->select([ - 'DiagnosID', - 'rf_MKBID', - ])->with(['mkb' => function ($mkbQuery) { - $mkbQuery->select([ - 'MKBID', - 'DS', - 'NAME', - ]); - }]); - }]); - }, - ]) - ->orderBy('DateRecipient', 'DESC') - ->get() - ->map(function ($patient) use ($reanimationDateByMedicalHistory, $reanimationCompleteByMedicalHistory) { - $reanimationDateIn = $reanimationDateByMedicalHistory->get($patient->MedicalHistoryID); - if ($reanimationDateIn) { - $patient->DateRecipient = $reanimationDateIn; - } - $patient->reanimation_is_complete = (bool) $reanimationCompleteByMedicalHistory->get($patient->MedicalHistoryID, false); - - return $patient; - }); + return $this->misClinicalDataSource->getReanimationPatients($branchId, $dateRange, $onlyIds); } /** - * Получить пациентов с операциями + * Получить операции пациентов по типу срочности. */ public function getSurgicalPatients( string $type, @@ -368,26 +134,11 @@ class PatientService DateRange $dateRange, bool $countOnly = false ) { - $query = MisSurgicalOperation::where('rf_StationarBranchID', $branchId) - ->completed() - ->where('Date', '>=', $dateRange->startSql()) - ->where('Date', '<=', $dateRange->endSql()); - - if ($type === 'plan') { - $query->where('rf_TypeSurgOperationInTimeID', 6); - } else { - $query->whereIn('rf_TypeSurgOperationInTimeID', [4, 5]); - } - - if ($countOnly) { - return $query->count(); - } - - return $query->get(); + return $this->misClinicalDataSource->getSurgicalPatients($type, $branchId, $dateRange, $countOnly); } /** - * Получить количество пациентов по типу с учетом уже находящихся в отделении + * Получить количество пациентов по типу с учетом уже находящихся в отделении. */ public function getPatientsCountWithCurrent( ?string $type, @@ -395,92 +146,6 @@ class PatientService int $branchId, DateRange $dateRange ): int { - return $this->getPlanOrEmergencyPatients( - $type, - $isHeadOrAdmin, - $branchId, - $dateRange, - true, - false, - true - ); - } - - private function buildPatientCardsQuery(array $medicalHistoryIds, int $branchId) - { - return MisMedicalHistory::query() - ->whereIn('MedicalHistoryID', $medicalHistoryIds) - ->select([ - 'MedicalHistoryID', - 'FAMILY', - 'Name', - 'OT', - 'BD', - 'DateRecipient', - 'DateExtract', - 'DateDeath', - 'rf_EmerSignID', - 'rf_kl_VisitResultID', - ]) - ->with([ - 'surgicalOperations' => function ($q) { - $q->select([ - 'SurgicalOperationID', - 'rf_MedicalHistoryID', - 'rf_kl_ServiceMedicalID', - 'Date', - ])->with(['serviceMedical' => function ($serviceQuery) { - $serviceQuery->select([ - 'ServiceMedicalID', - 'ServiceMedicalCode', - 'ServiceMedicalName', - ]); - }]); - }, - 'outcomeMigration' => function ($q) { - $q->select([ - 'stt_migrationpatient.MigrationPatientID', - 'stt_migrationpatient.rf_MedicalHistoryID', - 'stt_migrationpatient.DateOut', - 'stt_migrationpatient.rf_DiagnosID', - ])->with(['mainDiagnosis' => function ($diagnosisQuery) { - $diagnosisQuery->select([ - 'DiagnosID', - 'rf_MKBID', - ])->with(['mkb' => function ($mkbQuery) { - $mkbQuery->select([ - 'MKBID', - 'DS', - 'NAME', - ]); - }]); - }]); - }, - 'migrations' => function ($q) use ($branchId) { - $q->where('rf_StationarBranchID', $branchId) - ->select([ - 'MigrationPatientID', - 'rf_MedicalHistoryID', - 'rf_DiagnosID', - 'DateIngoing', - 'rf_StationarBranchID', - ]) - ->orderByDesc('DateIngoing') - ->with(['mainDiagnosis' => function ($diagnosisQuery) { - $diagnosisQuery->select([ - 'DiagnosID', - 'rf_MKBID', - 'rf_MigrationPatientID', - ])->with(['mkb' => function ($mkbQuery) { - $mkbQuery->select([ - 'MKBID', - 'DS', - 'NAME', - ]); - }]); - }]); - }, - ]) - ->orderBy('DateRecipient', 'DESC'); + return $this->misClinicalDataSource->getPatientsCountWithCurrent($type, $isHeadOrAdmin, $branchId, $dateRange); } } diff --git a/app/Services/ReportService.php b/app/Services/ReportService.php index d9d77cd..510465a 100644 --- a/app/Services/ReportService.php +++ b/app/Services/ReportService.php @@ -2,6 +2,11 @@ namespace App\Services; +use App\Domain\Reports\ValueObjects\MetrikaConfig; +use App\Infrastructure\Reports\Services\AutoFillReportPayloadBuilder; +use App\Infrastructure\Reports\Services\CalculatedMetricsSynchronizer; +use App\Infrastructure\Reports\Services\ReportPatientsReadService; +use App\Infrastructure\Reports\Services\ReportMetricsFinalizer; use App\Models\Department; use App\Models\DepartmentPatient; use App\Models\DepartmentPatientOperation; @@ -28,8 +33,25 @@ class ReportService protected UnifiedPatientService $unifiedPatientService, protected PatientService $patientQueryService, protected SnapshotService $snapshotService, - protected StatisticsService $statisticsService - ) {} + protected StatisticsService $statisticsService, + ?ReportMetricsFinalizer $reportMetricsFinalizer = null, + ?CalculatedMetricsSynchronizer $calculatedMetricsSynchronizer = null, + ?AutoFillReportPayloadBuilder $autoFillReportPayloadBuilder = null, + ?ReportPatientsReadService $reportPatientsReadService = null, + ) { + $this->reportMetricsFinalizer = $reportMetricsFinalizer ?? app(ReportMetricsFinalizer::class); + $this->calculatedMetricsSynchronizer = $calculatedMetricsSynchronizer ?? app(CalculatedMetricsSynchronizer::class); + $this->autoFillReportPayloadBuilder = $autoFillReportPayloadBuilder ?? app(AutoFillReportPayloadBuilder::class); + $this->reportPatientsReadService = $reportPatientsReadService ?? app(ReportPatientsReadService::class); + } + + protected ReportMetricsFinalizer $reportMetricsFinalizer; + + protected CalculatedMetricsSynchronizer $calculatedMetricsSynchronizer; + + protected AutoFillReportPayloadBuilder $autoFillReportPayloadBuilder; + + protected ReportPatientsReadService $reportPatientsReadService; /** * Получить статистику для отчета @@ -49,78 +71,6 @@ class ReportService return $this->getStatisticsFromReplica($department, $user, $dateRange, $branchId); } - public function shouldUseSnapshotsForPage(Department $department, User $user, DateRange $dateRange): bool - { - return $this->shouldUseSnapshots($department, $user, $dateRange); - } - - public function getFastReplicaStatisticsFromPatientsPayload( - Department $department, - User $user, - DateRange $dateRange, - array $patientsPayload - ): array { - $branchId = $this->getBranchId($department->rf_mis_department_id); - - $planCount = count($patientsPayload['mis-plan'] ?? []) + count($patientsPayload['special-plan'] ?? []); - $emergencyCount = count($patientsPayload['mis-emergency'] ?? []) + count($patientsPayload['special-emergency'] ?? []); - $dischargedCount = count($patientsPayload['mis-outcome-discharged'] ?? []) + count($patientsPayload['special-outcome-discharged'] ?? []); - $deadCount = count($patientsPayload['mis-outcome-deceased'] ?? []) + count($patientsPayload['special-outcome-deceased'] ?? []); - $outcomeCount = $dischargedCount + $deadCount; - - $recipientPatients = $this->unifiedPatientService - ->getLivePatientsByStatus($department, $user, 'recipient', $dateRange, $branchId); - $recipientCount = $recipientPatients->count(); - $recipientIds = $recipientPatients->pluck('id')->all(); - - $currentCount = $this->unifiedPatientService - ->getLivePatientCountByStatus($department, $user, 'current', $dateRange, $branchId); - - $misSurgicalCount = [ - $this->patientQueryService->getSurgicalPatients( - 'emergency', - $branchId, - $dateRange, - true - ), - $this->patientQueryService->getSurgicalPatients( - 'plan', - $branchId, - $dateRange, - true - ), - ]; - $manualSurgicalCount = $this->getManualSurgicalCounts($department, $dateRange); - $surgicalCount = [ - ($misSurgicalCount[0] ?? 0) + ($manualSurgicalCount[0] ?? 0), - ($misSurgicalCount[1] ?? 0) + ($manualSurgicalCount[1] ?? 0), - ]; - - $misBranch = MisStationarBranch::where('StationarBranchID', $branchId)->first(); - $beds = Department::where('rf_mis_department_id', $misBranch->rf_DepartmentID) - ->first()->metrikaDefault->where('rf_metrika_item_id', 1)->first(); - - if ($outcomeCount == 0) { - $percentDead = 0; - } else { - $percentDead = ($deadCount / $outcomeCount) * 100; - $percentDead = round($percentDead, 2); - } - - return [ - 'recipientCount' => $recipientCount, - 'extractCount' => $outcomeCount, - 'currentCount' => $currentCount, - 'deadCount' => $deadCount, - 'surgicalCount' => $surgicalCount, - 'recipientIds' => $recipientIds, - 'planCount' => $planCount, - 'emergencyCount' => $emergencyCount, - 'percentDead' => $percentDead, - 'beds' => $beds->value, - ]; - } - /** * Создать или обновить отчет */ @@ -147,14 +97,8 @@ class ReportService }); DB::transaction(function () use ($report) { - // Сохраняем метрику койко-дня + среднего койко-дня из снапшотов - $this->saveBedDaysMetric($report); - + $this->reportMetricsFinalizer->finalize($report); $this->saveLethalMetricFromSnapshots($report); - - $this->savePreoperativeMetric($report); - - $this->saveDepartmentLoadedMetric($report); }); } catch (\Throwable $e) { throw $e; @@ -165,6 +109,31 @@ class ReportService return $report; } + public function prepareForHeavySave(): void + { + $this->prepareMemoryForHeavySave(); + } + + public function syncCalculatedMetricsForStoredReport(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 saveLethalMetricForStoredReport(Report $report): void + { + $this->saveLethalMetricFromSnapshots($report); + } + + public function clearCacheAfterStoredReport(User $user, Report $report): void + { + $this->clearCacheAfterReportCreation($user, $report); + } + private function prepareMemoryForHeavySave(): void { $connectionNames = array_unique(array_filter([ @@ -191,146 +160,7 @@ class ReportService public function buildAutoFillReportPayload(User $user, Department $department, DateRange $dateRange): array { - $branchId = $this->getBranchId($department->rf_mis_department_id); - - $metrics = $this->buildAutoFillMetrics($department, $user, $branchId, $dateRange); - - return [ - 'departmentId' => $department->department_id, - 'userId' => $user->rf_lpudoctor_id ?? $user->id, - 'dates' => [ - $dateRange->startTimestamp(), - $dateRange->endTimestamp(), - ], - 'sent_at' => $dateRange->endSql(), - 'created_at' => $dateRange->endSql(), - 'status' => 'submitted', - 'metrics' => [ - 'metrika_item_4' => $metrics['plan'], - 'metrika_item_12' => $metrics['emergency'], - 'metrika_item_3' => $metrics['recipient'], - 'metrika_item_7' => $metrics['discharged'] + $metrics['deceased'], - 'metrika_item_8' => $metrics['current'], - 'metrika_item_9' => $metrics['deceased'], - 'metrika_item_10' => $metrics['emergency_surgery'], - 'metrika_item_11' => $metrics['plan_surgery'], - 'metrika_item_13' => $metrics['transferred'], - 'metrika_item_14' => 0, - 'metrika_item_15' => $metrics['discharged'], - ], - 'observationPatients' => [], - 'unwantedEvents' => [], - ]; - } - - private function buildAutoFillMetrics(Department $department, User $user, int $branchId, DateRange $dateRange): array - { - $manualSurgicalCount = $this->getManualSurgicalCounts($department, $dateRange); - $recipientQuery = $this->buildRecipientMedicalHistoryQuery($branchId, $dateRange); - $dischargeCodes = [1, 11, 2, 12, 7, 18, 48]; - $deceasedCodes = [5, 6, 15, 16]; - $transferCodes = [4, 14]; - - $planRecipient = (clone $recipientQuery) - ->where('rf_EmerSignID', 1) - ->distinct() - ->count('MedicalHistoryID'); - - $emergencyRecipient = (clone $recipientQuery) - ->whereIn('rf_EmerSignID', [2, 4]) - ->distinct() - ->count('MedicalHistoryID'); - - $recipientTotal = (clone $recipientQuery) - ->distinct() - ->count('MedicalHistoryID'); - - $discharged = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $dischargeCodes); - $deceased = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $deceasedCodes); - $transferred = $this->countOutcomeByVisitResultIds($branchId, $dateRange, $transferCodes); - - return [ - 'plan' => $planRecipient, - 'emergency' => $emergencyRecipient, - 'recipient' => $recipientTotal, - 'discharged' => $discharged, - 'transferred' => $transferred, - 'deceased' => $deceased, - 'current' => $this->unifiedPatientService->getLivePatientCountByStatus($department, $user, 'current', $dateRange, $branchId, null, true), - 'plan_surgery' => $this->patientQueryService->getSurgicalPatients( - 'plan', - $branchId, - $dateRange, - true - ) + ($manualSurgicalCount[1] ?? 0), - 'emergency_surgery' => $this->patientQueryService->getSurgicalPatients( - 'emergency', - $branchId, - $dateRange, - true - ) + ($manualSurgicalCount[0] ?? 0), - ]; - } - - private function buildRecipientMedicalHistoryQuery(int $branchId, DateRange $dateRange) - { - $startAt = $dateRange->start()->copy()->subDay()->format('Y-m-d H:i:s'); - $endAt = $dateRange->end()->copy()->addDay()->format('Y-m-d H:i:s'); - - if ($dateRange->isOneDay) { - $startAt = $dateRange->startSql(); - $endAt = $dateRange->endSql(); - } - - return MisMedicalHistory::query() - ->where('MedicalHistoryID', '<>', 0) - ->whereExists(function ($query) use ($branchId, $startAt, $endAt) { - $query->select(DB::raw(1)) - ->from('stt_migrationpatient as mp') - ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') - ->where('mp.rf_StationarBranchID', $branchId) - ->where('mp.DateIngoing', '>', $startAt) - ->where('mp.DateIngoing', '<=', $endAt); - }); - } - - private function buildTreatedMedicalHistoryQuery(int $branchId, DateRange $dateRange) - { - $query = MisMedicalHistory::query() - ->where('MedicalHistoryID', '<>', 0) - ->whereExists(function ($query) use ($branchId) { - $query->select(DB::raw(1)) - ->from('stt_migrationpatient as mp') - ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') - ->where('mp.rf_StationarBranchID', $branchId); - }); - - if ($dateRange->isOneDay) { - return $query - ->where('DateExtract', '>', $dateRange->startSql()) - ->where('DateExtract', '<=', $dateRange->endSql()); - } - - $startAt = $dateRange->startSql(); - $endDate = $dateRange->end()->toDateString(); - - return $query - ->where('DateExtract', '>', $startAt) - ->whereDate('DateExtract', '<=', $endDate); - } - - private function countOutcomeByVisitResultIds(int $branchId, DateRange $dateRange, array $visitResultIds): int - { - return $this->buildTreatedMedicalHistoryQuery($branchId, $dateRange) - ->whereExists(function ($query) use ($branchId, $visitResultIds) { - $query->select(DB::raw(1)) - ->from('stt_migrationpatient as mp') - ->whereColumn('mp.rf_MedicalHistoryID', 'stt_medicalhistory.MedicalHistoryID') - ->where('mp.rf_StationarBranchID', $branchId) - ->whereIn('mp.rf_kl_VisitResultID', $visitResultIds); - }) - ->distinct() - ->count('MedicalHistoryID'); + return $this->autoFillReportPayloadBuilder->build($user, $department, $dateRange); } /** @@ -344,7 +174,7 @@ class ReportService MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 25, // койко-дни + 'rf_metrika_item_id' => MetrikaConfig::TOTAL_BED_DAYS, ], ['value' => $result['total_days']] ); @@ -352,7 +182,7 @@ class ReportService MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 18, // средний койко-день + 'rf_metrika_item_id' => MetrikaConfig::AVERAGE_BED_DAYS, ], ['value' => $result['avg_days']] ); @@ -513,7 +343,7 @@ class ReportService MetrikaResult::updateOrCreate( [ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 18, + 'rf_metrika_item_id' => MetrikaConfig::AVERAGE_BED_DAYS, ], ['value' => 0] ); @@ -532,9 +362,9 @@ class ReportService try { $result = $this->calculatePreoperativeDaysFromSnapshots($report); - $this->saveMetric($report, 26, $result['total_days']); - $this->saveMetric($report, 27, $result['patient_count']); - $this->saveMetric($report, 21, $result['avg_days']); + $this->saveMetric($report, MetrikaConfig::TOTAL_PREOPERATIVE_DAYS, $result['total_days']); + $this->saveMetric($report, MetrikaConfig::PREOPERATIVE_PATIENT_COUNT, $result['patient_count']); + $this->saveMetric($report, MetrikaConfig::PREOPERATIVE_AVERAGE_DAYS, $result['avg_days']); } catch (\Throwable $e) { \Log::error('Failed to save preoperative total metric: '.$e->getMessage()); } @@ -546,12 +376,12 @@ class ReportService protected function saveDepartmentLoadedMetric(Report $report): void { // Получаем все снапшоты выписанных пациентов из этого отчета - $currentCount = $report->metrikaResults()->where('rf_metrika_item_id', 8)->value('value'); - $bedsCount = $report->metrikaResults()->where('rf_metrika_item_id', 1)->value('value'); + $currentCount = $report->metrikaResults()->where('rf_metrika_item_id', MetrikaConfig::CURRENT)->value('value'); + $bedsCount = $report->metrikaResults()->where('rf_metrika_item_id', MetrikaConfig::BEDS)->value('value'); $percentLoaded = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0; - $this->saveMetric($report, 22, $percentLoaded); + $this->saveMetric($report, MetrikaConfig::DEPARTMENT_LOADED, $percentLoaded); } /** @@ -602,48 +432,13 @@ class ReportService bool $beforeCreate = false, ?bool $includeCurrentPatients = null ) { - [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); - $branchId = $this->getBranchId($department->rf_mis_department_id); - - if ($sourceScope === 'special') { - return $this->getPatientsFromReplica( - $department, - $user, - $status, - $dateRange, - $branchId, - $onlyIds, - $includeCurrentPatients - ); - } - - // Для реанимации всегда берем live-данные из реплики. - if ($baseStatus === 'reanimation') { - return $this->getPatientsFromReplica( - $department, - $user, - $status, - $dateRange, - $branchId, - $onlyIds, - $includeCurrentPatients - ); - } - - $useSnapshots = ! $this->shouldUseReplicaForLiveStatus($user, $baseStatus, $dateRange) - && $this->shouldUseSnapshots($department, $user, $dateRange, $beforeCreate); - - if ($useSnapshots) { - return $this->getPatientsFromSnapshots($department, $status, $dateRange, $branchId, $onlyIds); - } - - return $this->getPatientsFromReplica( + return $this->reportPatientsReadService->getPatientsByStatus( $department, $user, $status, $dateRange, - $branchId, $onlyIds, + $beforeCreate, $includeCurrentPatients ); } @@ -657,78 +452,12 @@ class ReportService string $status, DateRange $dateRange ): int { - [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); - $branchId = $this->getBranchId($department->rf_mis_department_id); - - if ($sourceScope === 'special') { - return $this->getPatientsCountFromReplica($department, $user, $status, $dateRange, $branchId); - } - - if ($baseStatus === 'reanimation') { - return $this->getPatientsCountFromReplica($department, $user, $status, $dateRange, $branchId); - } - - $useSnapshots = ! $this->shouldUseReplicaForLiveStatus($user, $baseStatus, $dateRange) - && $this->shouldUseSnapshots($department, $user, $dateRange); - - if ($useSnapshots) { - return $this->getPatientsCountFromSnapshots($department, $status, $dateRange); - } - - return $this->getPatientsCountFromReplica($department, $user, $status, $dateRange, $branchId); + return $this->reportPatientsReadService->getPatientsCountByStatus($department, $user, $status, $dateRange); } public function getPatientsCountsMap(Department $department, User $user, DateRange $dateRange): array { - $baseStatuses = [ - 'plan', - 'emergency', - 'observation', - 'reanimation', - 'outcome-discharged', - 'outcome-deceased', - 'outcome-transferred', - ]; - - $counts = [ - 'mis-plan' => 0, - 'mis-emergency' => 0, - 'mis-observation' => 0, - 'mis-reanimation' => 0, - 'mis-outcome' => 0, - 'mis-outcome-discharged' => 0, - 'mis-outcome-deceased' => 0, - 'mis-outcome-transferred' => 0, - 'special-plan' => 0, - 'special-emergency' => 0, - 'special-observation' => 0, - 'special-reanimation' => 0, - 'special-outcome' => 0, - 'special-outcome-discharged' => 0, - 'special-outcome-deceased' => 0, - 'special-outcome-transferred' => 0, - ]; - - foreach ($baseStatuses as $baseStatus) { - $counts["mis-{$baseStatus}"] = $this->getPatientsCountByStatus( - $department, - $user, - "mis-{$baseStatus}", - $dateRange - ); - $counts["special-{$baseStatus}"] = $this->getPatientsCountByStatus( - $department, - $user, - "special-{$baseStatus}", - $dateRange - ); - } - - // Выбывшие = выписанные + умершие (без переведенных) - $counts['mis-outcome'] = ($counts['mis-outcome-discharged'] ?? 0) + ($counts['mis-outcome-deceased'] ?? 0); - $counts['special-outcome'] = ($counts['special-outcome-discharged'] ?? 0) + ($counts['special-outcome-deceased'] ?? 0); - - return $counts; + return $this->reportPatientsReadService->getPatientsCountsMap($department, $user, $dateRange); } /** @@ -761,17 +490,6 @@ class ReportService return true; } - private function shouldUseReplicaForLiveStatus(User $user, string $status, DateRange $dateRange): bool - { - if ($user->isHeadOfDepartment() || $user->isAdmin()) { - return false; - } - - return in_array($status, ['plan', 'emergency', 'recipient', 'current', 'reanimation'], true) - && $dateRange->isOneDay - && $dateRange->isEndDateToday(); - } - /** * Создать или обновить отчет */ @@ -811,7 +529,7 @@ class ReportService $beds = $department->metrikaDefault->where('rf_metrika_item_id', 1)->first(); MetrikaResult::create([ 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => 1, + 'rf_metrika_item_id' => MetrikaConfig::BEDS, 'value' => $beds->value, ]); } @@ -862,7 +580,7 @@ class ReportService { if (empty($unwantedEvents)) { $report->unwantedEvents()->delete(); - $this->saveMetric($report, 16, 0); + $this->saveMetric($report, MetrikaConfig::UNWANTED_EVENTS, 0); return; } @@ -889,7 +607,7 @@ class ReportService } // Обновить метрику - $this->saveMetric($report, 16, count($unwantedEvents)); + $this->saveMetric($report, MetrikaConfig::UNWANTED_EVENTS, count($unwantedEvents)); } /** @@ -905,7 +623,7 @@ class ReportService ->where('rf_report_id', $report->report_id) ->delete(); // Обновить метрику - $this->saveMetric($report, 14, 0); + $this->saveMetric($report, MetrikaConfig::OBSERVATION, 0); return; } @@ -926,82 +644,12 @@ class ReportService } // Обновить метрику - $this->saveMetric($report, 14, count($observationPatients)); + $this->saveMetric($report, MetrikaConfig::OBSERVATION, count($observationPatients)); } private function syncCalculatedMetrics(Report $report, User $user, array $data): void { - if (! isset($data['dates'][0], $data['dates'][1])) { - return; - } - - $department = Department::query()->where('department_id', $report->rf_department_id)->first(); - if (! $department) { - return; - } - - $dateRange = $this->dateRangeService->getNormalizedDateRange( - $user, - (string) $data['dates'][0], - (string) $data['dates'][1] - ); - - $branchId = $this->getBranchId($department->rf_mis_department_id); - - $planCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['plan']); - $emergencyCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['emergency']); - $recipientCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['recipient']); - $dischargedCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['discharged']); - $transferredCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['transferred']); - $deceasedCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['deceased']); - $currentCount = $this->countUniqueSnapshotsForTypes($report->report_id, ['current']); - $outcomeCount = $dischargedCount + $deceasedCount; - - $manualSurgicalCount = $this->getManualSurgicalCounts($department, $dateRange); - $misEmergencySurgery = $branchId - ? $this->patientQueryService->getSurgicalPatients('emergency', $branchId, $dateRange, true) - : 0; - $misPlanSurgery = $branchId - ? $this->patientQueryService->getSurgicalPatients('plan', $branchId, $dateRange, true) - : 0; - - $observationCount = ObservationPatient::query() - ->where('rf_department_id', $department->department_id) - ->where('rf_report_id', $report->report_id) - ->count(); - - $unwantedEventsCount = UnwantedEvent::query() - ->where('rf_report_id', $report->report_id) - ->count(); - - $this->saveMetric($report, 3, $recipientCount); - $this->saveMetric($report, 4, $planCount); - $this->saveMetric($report, 7, $outcomeCount); - $this->saveMetric($report, 8, $currentCount); - $this->saveMetric($report, 9, $deceasedCount); - $this->saveMetric($report, 10, $misEmergencySurgery + ($manualSurgicalCount[0] ?? 0)); - $this->saveMetric($report, 11, $misPlanSurgery + ($manualSurgicalCount[1] ?? 0)); - $this->saveMetric($report, 12, $emergencyCount); - $this->saveMetric($report, 13, $transferredCount); - $this->saveMetric($report, 14, $observationCount); - $this->saveMetric($report, 15, $dischargedCount); - $this->saveMetric($report, 16, $unwantedEventsCount); - } - - private function countUniqueSnapshotsForTypes(int $reportId, array $patientTypes): int - { - return MedicalHistorySnapshot::query() - ->where('rf_report_id', $reportId) - ->whereIn('patient_type', $patientTypes) - ->get(['medical_history_snapshot_id', 'patient_uid', 'rf_medicalhistory_id']) - ->map(function (MedicalHistorySnapshot $snapshot) { - return $snapshot->patient_uid - ?: ($snapshot->rf_medicalhistory_id - ? "mis:{$snapshot->rf_medicalhistory_id}" - : "snapshot:{$snapshot->medical_history_snapshot_id}"); - }) - ->unique() - ->count(); + $this->calculatedMetricsSynchronizer->sync($report, $user, $data); } /** @@ -1463,38 +1111,6 @@ class ReportService ]; } - private function getManualSurgicalCounts(Department $department, DateRange $dateRange): array - { - $baseQuery = DepartmentPatientOperation::query() - ->whereBetween('started_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->whereHas('patient', function ($query) use ($department) { - $query->where('rf_department_id', $department->department_id) - ->whereIn('source_type', ['manual', 'special']); - }); - - $emergencyCount = (clone $baseQuery) - ->where(function ($query) { - $query->where('urgency', 'emergency') - ->orWhere(function ($fallback) { - $fallback->whereNull('urgency') - ->whereHas('patient', fn ($patientQuery) => $patientQuery->where('patient_kind', 'emergency')); - }); - }) - ->count(); - - $planCount = (clone $baseQuery) - ->where(function ($query) { - $query->where('urgency', 'plan') - ->orWhere(function ($fallback) { - $fallback->whereNull('urgency') - ->whereHas('patient', fn ($patientQuery) => $patientQuery->where('patient_kind', 'plan')); - }); - }) - ->count(); - - return [$emergencyCount, $planCount]; - } - /** * Получить пациентов из снапшотов */ @@ -1505,177 +1121,15 @@ class ReportService int $branchId, bool $onlyIds = false ) { - [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); - $reports = $this->getReportsForDateRange( - $department->department_id, - $dateRange - ); - - $reportIds = $reports->pluck('report_id')->toArray(); - $recipientReportIds = $this->getSnapshotRecipientReportIds($reportIds); - - $patientTypeMap = [ - 'plan' => 'plan', - 'emergency' => 'emergency', - 'current' => 'current', - 'recipient' => 'recipient', - 'outcome-discharged' => 'discharged', - 'outcome-transferred' => 'transferred', - 'outcome-deceased' => 'deceased', - 'observation' => 'observation', - ]; - - $patientType = $patientTypeMap[$baseStatus] ?? null; - - if ($patientType === 'observation') { - return $this->unifiedPatientService->getObservationPatients($department, $onlyIds, $sourceScope); - } - - if ($baseStatus === 'outcome') { - $discharged = $this->snapshotService->getPatientsFromSnapshots( - 'discharged', - $reportIds, - $branchId, - false, - false, - $recipientReportIds - ); - $deceased = $this->snapshotService->getPatientsFromSnapshots( - 'deceased', - $reportIds, - $branchId, - false, - false, - $recipientReportIds - ); - - $merged = \App\Data\UnifiedPatientData::unique($discharged->concat($deceased)) - ->sortByDesc(fn (\App\Data\UnifiedPatientData $patient) => $patient->admittedAt ?? '') - ->values(); - - return $this->filterSnapshotPatientsByScope($merged, $sourceScope, $onlyIds); - } - - if (! $patientType) { - return collect(); - } - - if ($dateRange->isOneDay && in_array($baseStatus, ['plan', 'emergency'], true)) { - $patients = $this->snapshotService->getPatientsFromOneDayCurrentSnapshots( - $patientType, - $reportIds, - false, - $recipientReportIds - ); - - return $this->filterSnapshotPatientsByScope($patients, $sourceScope, $onlyIds); - } - - $patients = $this->snapshotService->getPatientsFromSnapshots( - $patientType, - $reportIds, - $branchId, - false, - in_array($baseStatus, ['plan', 'emergency'], true), - $recipientReportIds - ); - - return $this->filterSnapshotPatientsByScope($patients, $sourceScope, $onlyIds); - } - - /** - * Получить пациентов из реплики БД - */ - private function getPatientsFromReplica( - Department $department, - User $user, - string $status, - DateRange $dateRange, - int $branchId, - bool $onlyIds = false, - ?bool $isIncludeCurrent = null - ) { - [$baseStatus] = $this->parseScopedStatus($status); - - // Для плановых и экстренных включаем уже лечащихся - $includeCurrent = $isIncludeCurrent ?? in_array($baseStatus, ['plan', 'emergency', 'reanimation'], true); - - return $this->unifiedPatientService->getLivePatientsByStatus( + return $this->reportPatientsReadService->getPatientsFromSnapshots( $department, - $user, $status, $dateRange, $branchId, - $onlyIds, - $includeCurrent + $onlyIds ); } - /** - * Получить количество пациентов из снапшотов - */ - private function getPatientsCountFromSnapshots(Department $department, string $status, DateRange $dateRange): int - { - [$baseStatus, $sourceScope] = $this->parseScopedStatus($status); - $reports = $this->getReportsForDateRange( - $department->department_id, - $dateRange - ); - - $reportIds = $reports->pluck('report_id')->toArray(); - - if ($baseStatus === 'outcome') { - if ($sourceScope !== 'all') { - return $this->getPatientsFromSnapshots( - $department, - $status, - $dateRange, - $this->getBranchId($department->rf_mis_department_id), - false - )->count(); - } - - return MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) - ->whereIn('patient_type', ['discharged', 'deceased']) - ->distinct('rf_medicalhistory_id') - ->count('rf_medicalhistory_id'); - } - - $patientTypeMap = [ - 'plan' => 'plan', - 'emergency' => 'emergency', - 'observation' => 'observation', - 'outcome-discharged' => 'discharged', - 'outcome-transferred' => 'transferred', - 'outcome-deceased' => 'deceased', - ]; - - $patientType = $patientTypeMap[$baseStatus] ?? null; - - if (! $patientType) { - return 0; - } - - if ($patientType === 'observation') { - return $this->unifiedPatientService->getObservationPatients($department, false, $sourceScope)->count(); - } - - if ($sourceScope !== 'all') { - return $this->getPatientsFromSnapshots( - $department, - $status, - $dateRange, - $this->getBranchId($department->rf_mis_department_id), - false - )->count(); - } - - return MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) - ->where('patient_type', $patientType) - ->distinct('rf_medicalhistory_id') - ->count('rf_medicalhistory_id'); - } - private function getSnapshotRecipientReportIds(array $reportIds): array { if (empty($reportIds)) { @@ -1685,79 +1139,6 @@ class ReportService return [reset($reportIds)]; } - /** - * Получить количество пациентов из реплики БД - */ - private function getPatientsCountFromReplica( - Department $department, - User $user, - string $status, - DateRange $dateRange, - int $branchId - ): int { - [$baseStatus] = $this->parseScopedStatus($status); - - return match ($status) { - 'plan', 'emergency', 'observation', 'reanimation', 'outcome', 'outcome-discharged', 'outcome-transferred', 'outcome-deceased', 'current', 'recipient' => $this->unifiedPatientService->getLivePatientCountByStatus( - $department, - $user, - $status, - $dateRange, - $branchId, - in_array($status, ['plan', 'emergency'], true) - ), - default => $this->unifiedPatientService->getLivePatientCountByStatus( - $department, - $user, - $status, - $dateRange, - $branchId, - in_array($baseStatus, ['plan', 'emergency'], true) - ) - }; - } - - private function filterSnapshotPatientsByScope($patients, string $sourceScope, bool $onlyIds = false) - { - if ($sourceScope === 'all') { - return $onlyIds ? $patients->pluck('id') : $patients; - } - - $filtered = $patients->filter(function ($patient) use ($sourceScope) { - return match ($sourceScope) { - 'mis' => $patient->sourceType === 'mis', - 'special' => in_array($patient->sourceType, ['manual', 'special'], true), - default => true, - }; - })->values(); - - return $onlyIds ? $filtered->pluck('id') : $filtered; - } - - private function parseScopedStatus(string $status): array - { - foreach (['mis', 'special'] as $scope) { - $prefix = "{$scope}-"; - - if (str_starts_with($status, $prefix)) { - return [substr($status, strlen($prefix)), $scope]; - } - } - - return [$status, 'all']; - } - - private function isSpecialScopedPatient($patient): bool - { - $sourceType = $patient->sourceType ?? $patient->source_type ?? null; - - if ($sourceType !== null) { - return in_array($sourceType, ['manual', 'special'], true); - } - - return str_starts_with((string) ($patient->id ?? ''), 'manual:'); - } - /** * Получить нежелательные события за дату */ @@ -1892,70 +1273,6 @@ class ReportService return $count; } - /** - * Рассчитать текущих пациентов из снапшотов - */ - private function calculateCurrentPatientsFromSnapshots(array $reportIds, int $branchId): int - { - // Получаем ID всех пациентов из снапшотов - $allPatientIds = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) - ->pluck('rf_medicalhistory_id') - ->unique() - ->toArray(); - - if (empty($allPatientIds)) { - return 0; - } - - // Получаем ID выбывших пациентов - $outcomePatientIds = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) - ->whereIn('patient_type', ['discharged', 'transferred', 'deceased']) - ->pluck('rf_medicalhistory_id') - ->unique() - ->toArray(); - - // Текущие = все - выбывшие - $currentPatientIds = array_diff($allPatientIds, $outcomePatientIds); - - return count($currentPatientIds); - } - - /** - * Получить пациентов под наблюдением из снапшотов - */ - private function getObservationPatientsFromSnapshots(int $departmentId, array $reportIds, bool $onlyIds = false) - { - $medicalHistoryIds = ObservationPatient::whereIn('rf_report_id', $reportIds) - ->where('rf_department_id', $departmentId) - ->pluck('rf_medicalhistory_id') - ->unique() - ->toArray(); - - if (empty($medicalHistoryIds)) { - return collect(); - } - - if ($onlyIds) { - return collect($medicalHistoryIds); - } - - return MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - ->with(['observationPatient' => function ($query) use ($departmentId) { - $query->where('rf_department_id', $departmentId) - ->select(['rf_medicalhistory_id', 'observation_patient_id', 'comment']); - }]) - ->orderBy('DateRecipient', 'DESC') - ->get() - ->map(function ($patient) { - $patient->comment = $patient->observationPatient - ->pluck('comment') - ->filter() - ->implode('; '); - - return $patient; - }); - } - /** * Получить статистику выполнения плана по госпитализации */ diff --git a/app/Services/Reports/PatientQueryBuilder.php b/app/Services/Reports/PatientQueryBuilder.php index bf0538a..cb4a60e 100644 --- a/app/Services/Reports/PatientQueryBuilder.php +++ b/app/Services/Reports/PatientQueryBuilder.php @@ -2,7 +2,117 @@ namespace App\Services\Reports; +use App\Models\MisMedicalHistory; +use App\Models\MisMigrationPatient; +use Illuminate\Support\Carbon; + class PatientQueryBuilder { + public function __construct( + private int $branchId, + private string $startDate, + private string $endDate, + private bool $isHeadOrAdmin + ) {} + public function forStatus(string $status, bool $onlyIds = false): mixed + { + $query = match ($status) { + 'plan', 'emergency' => $this->buildPlanEmergencyQuery($status), + 'outcome', 'outcome-transferred', 'outcome-deceased' => $this->buildOutcomeQuery($status), + 'recipient' => $this->buildRecipientQuery(), + 'current' => $this->buildCurrentQuery(), + default => throw new \InvalidArgumentException("Unknown status: $status"), + }; + + return $onlyIds ? $query->pluck('MedicalHistoryID')->values() : $query->get(); + } + + private function buildPlanEmergencyQuery(string $status) + { + // Логика из getPlanOrEmergencyPatients, но без if/else по роли внутри + $medicalHistoryIds = $this->isHeadOrAdmin + ? MisMigrationPatient::whereInDepartment($this->branchId) + ->whereBetween('DateIngoing', [$this->startDate, $this->endDate]) + ->pluck('rf_MedicalHistoryID') + : MisMigrationPatient::currentlyInTreatment($this->branchId) + ->whereBetween('DateIngoing', [$this->startDate, $this->endDate]) + ->pluck('rf_MedicalHistoryID'); + + if ($medicalHistoryIds->isEmpty()) { + return MisMedicalHistory::query()->whereRaw('1=0'); // пустой запрос + } + + $query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) + ->with(['surgicalOperations' => fn($q) => $q->whereBetween('Date', [$this->startDate, $this->endDate])]) + ->orderBy('DateRecipient', 'DESC'); + + if ($status === 'plan') { + $query->plan(); + } elseif ($status === 'emergency') { + $query->emergency(); + } + + if (! $this->isHeadOrAdmin) { + $query->currentlyHospitalized(); + } + + return $query; + } + + private function buildOutcomeQuery(string $status): \Illuminate\Database\Eloquent\Builder + { + $visitResultIds = match ($status) { + 'outcome-transferred' => [4, 14], + 'outcome-deceased' => [5, 6, 15, 16], + default => [1, 11, 2, 12, 7, 18, 48], // discharged + }; + + return MisMedicalHistory::query() + ->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString()) + ->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString()) + ->whereHas('migrations', function ($q) use ($visitResultIds) { + $q->where('rf_StationarBranchID', $this->branchId) + ->whereIn('rf_kl_VisitResultID', $visitResultIds); + }) + ->with(['surgicalOperations']) + ->orderBy('DateRecipient', 'DESC'); + } + + private function buildRecipientQuery(string $status): \Illuminate\Database\Eloquent\Builder + { + $visitResultIds = match ($status) { + 'outcome-transferred' => [4, 14], + 'outcome-deceased' => [5, 6, 15, 16], + default => [1, 11, 2, 12, 7, 18, 48], // discharged + }; + + return MisMedicalHistory::query() + ->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString()) + ->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString()) + ->whereHas('migrations', function ($q) use ($visitResultIds) { + $q->where('rf_StationarBranchID', $this->branchId) + ->whereIn('rf_kl_VisitResultID', $visitResultIds); + }) + ->with(['surgicalOperations']) + ->orderBy('DateRecipient', 'DESC'); + } + private function buildCurrentQuery(string $status): \Illuminate\Database\Eloquent\Builder + { + $visitResultIds = match ($status) { + 'outcome-transferred' => [4, 14], + 'outcome-deceased' => [5, 6, 15, 16], + default => [1, 11, 2, 12, 7, 18, 48], // discharged + }; + + return MisMedicalHistory::query() + ->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString()) + ->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString()) + ->whereHas('migrations', function ($q) use ($visitResultIds) { + $q->where('rf_StationarBranchID', $this->branchId) + ->whereIn('rf_kl_VisitResultID', $visitResultIds); + }) + ->with(['surgicalOperations']) + ->orderBy('DateRecipient', 'DESC'); + } } diff --git a/app/Services/SnapshotService.php b/app/Services/SnapshotService.php index 22ac57a..0234767 100644 --- a/app/Services/SnapshotService.php +++ b/app/Services/SnapshotService.php @@ -2,12 +2,11 @@ namespace App\Services; -use App\Data\UnifiedPatientData; +use App\Domain\Reports\ValueObjects\MetrikaConfig; +use App\Infrastructure\Reports\Services\SnapshotPersistenceService; +use App\Infrastructure\Reports\Sources\SnapshotPatientSource; use App\Models\Department; -use App\Models\DepartmentPatientOperation; use App\Models\MedicalHistorySnapshot; -use App\Models\MetrikaResult; -use App\Models\MisMedicalHistory; use App\Models\MisStationarBranch; use App\Models\Report; use App\Models\User; @@ -20,7 +19,16 @@ class SnapshotService protected UnifiedPatientService $unifiedPatientService, protected PatientService $patientService, protected DateRangeService $dateRangeService, - ) {} + ?SnapshotPersistenceService $snapshotPersistenceService = null, + ?SnapshotPatientSource $snapshotPatientSource = null, + ) { + $this->snapshotPersistenceService = $snapshotPersistenceService ?? app(SnapshotPersistenceService::class); + $this->snapshotPatientSource = $snapshotPatientSource ?? app(SnapshotPatientSource::class); + } + + protected SnapshotPersistenceService $snapshotPersistenceService; + + protected SnapshotPatientSource $snapshotPatientSource; /** * Создать снапшоты пациентов для отчета @@ -42,9 +50,7 @@ class SnapshotService return; } - MedicalHistorySnapshot::query() - ->where('rf_report_id', $report->report_id) - ->delete(); + $this->snapshotPersistenceService->clearReportSnapshots($report); $this->logSnapshotMemory('snapshots:after_delete_old', [ 'report_id' => $report->report_id, ]); @@ -69,9 +75,9 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $planPatients->count(), ]); - $this->createSnapshotsForType($report, 'plan', $planPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'plan', $planPatients); $this->logSnapshotMemory('snapshots:after_plan_save', ['report_id' => $report->report_id]); - $metrics[4] = $planPatients->count(); + $metrics[MetrikaConfig::PLAN] = $planPatients->count(); $this->logSnapshotMemory('snapshots:before_emergency_load', ['report_id' => $report->report_id]); $emergencyPatients = $this->unifiedPatientService->getLivePatientsByStatus( @@ -89,9 +95,9 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $emergencyPatients->count(), ]); - $this->createSnapshotsForType($report, 'emergency', $emergencyPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'emergency', $emergencyPatients); $this->logSnapshotMemory('snapshots:after_emergency_save', ['report_id' => $report->report_id]); - $metrics[12] = $emergencyPatients->count(); + $metrics[MetrikaConfig::EMERGENCY] = $emergencyPatients->count(); $this->logSnapshotMemory('snapshots:before_discharged_load', ['report_id' => $report->report_id]); $dischargedPatients = $this->unifiedPatientService->getLivePatientsByStatus( @@ -109,9 +115,9 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $dischargedPatients->count(), ]); - $this->createSnapshotsForType($report, 'discharged', $dischargedPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'discharged', $dischargedPatients); $this->logSnapshotMemory('snapshots:after_discharged_save', ['report_id' => $report->report_id]); - $metrics[15] = $dischargedPatients->count(); + $metrics[MetrikaConfig::DISCHARGED] = $dischargedPatients->count(); $this->logSnapshotMemory('snapshots:before_transferred_load', ['report_id' => $report->report_id]); $transferredPatients = $this->unifiedPatientService->getLivePatientsByStatus( @@ -129,9 +135,9 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $transferredPatients->count(), ]); - $this->createSnapshotsForType($report, 'transferred', $transferredPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'transferred', $transferredPatients); $this->logSnapshotMemory('snapshots:after_transferred_save', ['report_id' => $report->report_id]); - $metrics[13] = $transferredPatients->count(); + $metrics[MetrikaConfig::TRANSFERRED] = $transferredPatients->count(); $this->logSnapshotMemory('snapshots:before_deceased_load', ['report_id' => $report->report_id]); $deceasedPatients = $this->unifiedPatientService->getLivePatientsByStatus( @@ -149,7 +155,7 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $deceasedPatients->count(), ]); - $this->createSnapshotsForType($report, 'deceased', $deceasedPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'deceased', $deceasedPatients); $this->logSnapshotMemory('snapshots:after_deceased_save', ['report_id' => $report->report_id]); $this->logSnapshotMemory('snapshots:before_recipient_load', ['report_id' => $report->report_id]); @@ -168,7 +174,7 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $recipientPatients->count(), ]); - $this->createSnapshotsForType($report, 'recipient', $recipientPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'recipient', $recipientPatients); $this->logSnapshotMemory('snapshots:after_recipient_save', ['report_id' => $report->report_id]); $this->logSnapshotMemory('snapshots:before_current_load', ['report_id' => $report->report_id]); @@ -187,7 +193,7 @@ class SnapshotService 'report_id' => $report->report_id, 'count' => $currentPatients->count(), ]); - $this->createSnapshotsForType($report, 'current', $currentPatients); + $this->snapshotPersistenceService->createSnapshotsForType($report, 'current', $currentPatients); $this->logSnapshotMemory('snapshots:after_current_save', ['report_id' => $report->report_id]); $planSurgeryCount = $this->patientService->getSurgicalPatients( @@ -203,7 +209,7 @@ class SnapshotService true ); - $this->saveMetrics($report, $metrics); + $this->snapshotPersistenceService->saveMetrics($report, $metrics); $this->logSnapshotMemory('snapshots:after_save_metrics', ['report_id' => $report->report_id]); } @@ -217,38 +223,12 @@ class SnapshotService ]); } - /** - * Сохранить метрики в базу - */ - private function saveMetrics(Report $report, array $metrics): void - { - foreach ($metrics as $metrikaItemId => $value) { - MetrikaResult::updateOrCreate( - [ - 'rf_report_id' => $report->report_id, - 'rf_metrika_item_id' => $metrikaItemId, - ], - [ - 'value' => $value, - ] - ); - } - } - /** * Получить статистику из снапшотов */ public function getStatisticsFromSnapshots(array $reportIds): array { - return [ - 'plan' => $this->getCountFromSnapshots('plan', $reportIds), - 'emergency' => $this->getCountFromSnapshots('emergency', $reportIds), - 'outcome' => $this->getCountFromSnapshots('outcome', $reportIds), - 'deceased' => $this->getCountFromSnapshots('deceased', $reportIds), - 'discharged' => $this->getCountFromSnapshots('discharged', $reportIds), - 'transferred' => $this->getCountFromSnapshots('transferred', $reportIds), - 'recipient' => $this->getCountFromSnapshots('recipient', $reportIds), - ]; + return $this->snapshotPatientSource->getStatisticsFromSnapshots($reportIds); } /** @@ -262,62 +242,13 @@ class SnapshotService bool $markRecipients = false, ?array $recipientReportIds = null ): Collection { - $snapshots = MedicalHistorySnapshot::query() - ->whereIn('rf_report_id', $reportIds) - ->where('patient_type', $type) - ->get() - ->unique(fn (MedicalHistorySnapshot $snapshot) => $snapshot->patient_uid ?: ($snapshot->rf_medicalhistory_id ? "mis:{$snapshot->rf_medicalhistory_id}" : "snapshot:{$snapshot->medical_history_snapshot_id}")) - ->values(); - - if ($snapshots->isEmpty()) { - return collect(); - } - - $recipientIds = []; - if ($markRecipients) { - $recipientReportIds ??= $reportIds; - $recipientIds = MedicalHistorySnapshot::query() - ->whereIn('rf_report_id', $recipientReportIds) - ->where('patient_type', 'recipient') - ->get() - ->map(fn (MedicalHistorySnapshot $snapshot) => UnifiedPatientData::fromSnapshot($snapshot)->id) - ->unique() - ->values() - ->all(); - } - - $operationsByHistoryId = $this->getOperationsByMedicalHistoryId($snapshots); - $operationsByDepartmentPatientId = $this->getOperationsByDepartmentPatientId($snapshots); - - $patients = $snapshots->map(function (MedicalHistorySnapshot $snapshot) use ($recipientIds, $operationsByHistoryId, $operationsByDepartmentPatientId) { - $patientId = $snapshot->rf_department_patient_id - ? "manual:{$snapshot->rf_department_patient_id}" - : ($snapshot->patient_uid ?: "mis:{$snapshot->rf_medicalhistory_id}"); - - $misOperations = $snapshot->rf_medicalhistory_id - ? ($operationsByHistoryId[$snapshot->rf_medicalhistory_id] ?? []) - : []; - $manualOperations = $snapshot->rf_department_patient_id - ? ($operationsByDepartmentPatientId[$snapshot->rf_department_patient_id] ?? []) - : []; - $operations = collect($misOperations) - ->merge($manualOperations) - ->unique(fn (array $operation) => ($operation['code'] ?? '').'|'.($operation['name'] ?? '')) - ->values() - ->all(); - - return UnifiedPatientData::fromSnapshot( - $snapshot, - in_array($patientId, $recipientIds, true), - $operations - ); - })->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '')->values(); - - if ($onlyIds) { - return $patients->pluck('id'); - } - - return $patients; + return $this->snapshotPatientSource->getPatientsFromSnapshots( + $type, + $reportIds, + $onlyIds, + $markRecipients, + $recipientReportIds + ); } public function getPatientsFromOneDayCurrentSnapshots( @@ -326,112 +257,12 @@ class SnapshotService bool $onlyIds = false, ?array $recipientReportIds = null ): Collection { - $snapshots = MedicalHistorySnapshot::query() - ->whereIn('rf_report_id', $reportIds) - ->where('patient_type', 'current') - ->get(); - - if ($snapshots->isEmpty()) { - return $this->getPatientsFromSnapshots( - $type, - $reportIds, - null, - $onlyIds, - true, - $recipientReportIds - ); - } - - $recipientReportIds ??= $reportIds; - $recipientIds = MedicalHistorySnapshot::query() - ->whereIn('rf_report_id', $recipientReportIds) - ->where('patient_type', 'recipient') - ->get() - ->map(fn (MedicalHistorySnapshot $snapshot) => UnifiedPatientData::fromSnapshot($snapshot)->id) - ->unique() - ->values() - ->all(); - - $operationsByHistoryId = $this->getOperationsByMedicalHistoryId($snapshots); - $operationsByDepartmentPatientId = $this->getOperationsByDepartmentPatientId($snapshots); - - $patients = $snapshots - ->filter(fn (MedicalHistorySnapshot $snapshot) => $snapshot->patient_kind === $type) - ->unique(fn (MedicalHistorySnapshot $snapshot) => $snapshot->patient_uid ?: ($snapshot->rf_medicalhistory_id ? "mis:{$snapshot->rf_medicalhistory_id}" : "snapshot:{$snapshot->medical_history_snapshot_id}")) - ->map(function (MedicalHistorySnapshot $snapshot) use ($recipientIds, $operationsByHistoryId, $operationsByDepartmentPatientId) { - $misOperations = $snapshot->rf_medicalhistory_id - ? ($operationsByHistoryId[$snapshot->rf_medicalhistory_id] ?? []) - : []; - $manualOperations = $snapshot->rf_department_patient_id - ? ($operationsByDepartmentPatientId[$snapshot->rf_department_patient_id] ?? []) - : []; - $operations = collect($misOperations) - ->merge($manualOperations) - ->unique(fn (array $operation) => ($operation['code'] ?? '').'|'.($operation['name'] ?? '')) - ->values() - ->all(); - - $patient = UnifiedPatientData::fromSnapshot( - $snapshot, - false, - $operations - ); - $patient->isRecipientToday = in_array($patient->id, $recipientIds, true); - - return $patient; - }) - ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') - ->values(); - - if ($onlyIds) { - return $patients->pluck('id'); - } - - return $patients; - } - - /** - * Получить количество пациентов из снапшотов - */ - private function getCountFromSnapshots(string $type, array $reportIds): int - { - $query = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds); - - if ($type === 'outcome') { - $query->whereIn('patient_type', ['discharged', 'deceased']); - } else { - $query->where('patient_type', $type); - } - - return $query->get() - ->map(fn (MedicalHistorySnapshot $snapshot) => $snapshot->patient_uid ?: ($snapshot->rf_medicalhistory_id ? "mis:{$snapshot->rf_medicalhistory_id}" : "snapshot:{$snapshot->medical_history_snapshot_id}")) - ->unique() - ->count(); - } - - /** - * Создать снапшоты для определенного типа пациентов - */ - private function createSnapshotsForType(Report $report, string $type, Collection $patients): void - { - foreach ($patients as $patient) { - if (! $patient instanceof UnifiedPatientData) { - continue; - } - - MedicalHistorySnapshot::updateOrCreate( - [ - 'rf_report_id' => $report->report_id, - 'patient_uid' => $patient->patientUid, - 'patient_type' => $type, - ], - [ - 'rf_report_id' => $report->report_id, - 'patient_type' => $type, - ...$patient->toSnapshotPayload($type), - ] - ); - } + return $this->snapshotPatientSource->getPatientsFromOneDayCurrentSnapshots( + $type, + $reportIds, + $onlyIds, + $recipientReportIds + ); } /** @@ -453,47 +284,4 @@ class SnapshotService Carbon::createFromTimestampMs($dates[1])->setTimezone('Asia/Yakutsk'), ]; } - - private function getOperationsByMedicalHistoryId(Collection $snapshots): array - { - $historyIds = $snapshots->pluck('rf_medicalhistory_id')->filter()->unique()->values(); - - if ($historyIds->isEmpty()) { - return []; - } - - return MisMedicalHistory::query() - ->whereIn('MedicalHistoryID', $historyIds) - ->with(['surgicalOperations.serviceMedical']) - ->get() - ->mapWithKeys(function (MisMedicalHistory $history) { - return [ - $history->MedicalHistoryID => $history->surgicalOperations->map(fn ($operation) => [ - 'code' => $operation->serviceMedical?->ServiceMedicalCode, - 'name' => $operation->serviceMedical?->ServiceMedicalName, - ])->values()->all(), - ]; - }) - ->all(); - } - - private function getOperationsByDepartmentPatientId(Collection $snapshots): array - { - $departmentPatientIds = $snapshots->pluck('rf_department_patient_id')->filter()->unique()->values(); - - if ($departmentPatientIds->isEmpty()) { - return []; - } - - return DepartmentPatientOperation::query() - ->whereIn('rf_department_patient_id', $departmentPatientIds) - ->with('serviceMedical') - ->get() - ->groupBy('rf_department_patient_id') - ->map(fn (Collection $operations) => $operations->map(fn (DepartmentPatientOperation $operation) => [ - 'code' => $operation->serviceMedical?->ServiceMedicalCode ?? $operation->service_code, - 'name' => $operation->serviceMedical?->ServiceMedicalName ?? $operation->service_name, - ])->values()->all()) - ->all(); - } } diff --git a/app/Services/UnifiedPatientService.php b/app/Services/UnifiedPatientService.php index 59679df..65d8fcc 100644 --- a/app/Services/UnifiedPatientService.php +++ b/app/Services/UnifiedPatientService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Data\UnifiedPatientData; +use App\Infrastructure\Reports\Sources\MisPatientSource; +use App\Infrastructure\Reports\Sources\SpecialPatientSource; use App\Models\Department; use App\Models\DepartmentPatient; use App\Models\MisMedicalHistory; @@ -17,7 +19,16 @@ class UnifiedPatientService public function __construct( protected PatientService $patientService, - ) {} + ?MisPatientSource $misPatientSource = null, + ?SpecialPatientSource $specialPatientSource = null, + ) { + $this->misPatientSource = $misPatientSource ?? app(MisPatientSource::class); + $this->specialPatientSource = $specialPatientSource ?? app(SpecialPatientSource::class); + } + + protected MisPatientSource $misPatientSource; + + protected SpecialPatientSource $specialPatientSource; public function getLivePatientsByStatus( Department $department, @@ -37,8 +48,8 @@ class UnifiedPatientService } $patients = match ($sourceScope) { - 'mis' => $this->getMisPatientDtos($user, $baseStatus, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots), - 'special' => $this->getSpecialPatientDtos($department, $baseStatus, $dateRange, $forSnapshots), + 'mis' => $this->misPatientSource->getDtos($user, $baseStatus, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots), + 'special' => $this->specialPatientSource->getDtos($department, $baseStatus, $dateRange, self::SPECIAL_SOURCE_TYPES, $forSnapshots), default => $this->getAggregatedPatientDtos($department, $user, $baseStatus, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots), }; @@ -76,10 +87,10 @@ class UnifiedPatientService } if ($sourceScope === 'special') { - return $this->getManualPatientsCount($department, $baseStatus, $dateRange, self::SPECIAL_SOURCE_TYPES); + return $this->specialPatientSource->getCount($department, $baseStatus, $dateRange, self::SPECIAL_SOURCE_TYPES); } - $misCount = $this->getMisPatientsCount( + $misCount = $this->misPatientSource->getCount( $user, $baseStatus, $dateRange, @@ -92,7 +103,7 @@ class UnifiedPatientService return $misCount; } - $specialCount = $this->getManualPatientsCount($department, $baseStatus, $dateRange, self::SPECIAL_SOURCE_TYPES); + $specialCount = $this->specialPatientSource->getCount($department, $baseStatus, $dateRange, self::SPECIAL_SOURCE_TYPES); return $misCount + $specialCount; } @@ -117,7 +128,7 @@ class UnifiedPatientService $fillableAuto ); - $manualIds = $this->buildManualPatientsQuery($department, $dateRange, self::SPECIAL_SOURCE_TYPES, false) + $manualIds = $this->specialPatientSource->getPatients($department, 'recipient', $dateRange, self::SPECIAL_SOURCE_TYPES, false) ->whereBetween('admitted_at', [$dateRange->startSql(), $dateRange->endSql()]) ->pluck('department_patient_id'); @@ -275,25 +286,8 @@ class UnifiedPatientService bool $fillableAuto = false, bool $forSnapshots = false ): Collection { - $misPatients = $this->getMisPatients($user, $status, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots); - $manualPatients = $this->getManualPatients($department, $status, $dateRange, self::SPECIAL_SOURCE_TYPES, ! $forSnapshots); - $reportIds = $this->getReportIdsForDepartmentPeriod($department, $dateRange); - - $linkedManualPatients = DepartmentPatient::query() - ->where(function ($builder) use ($department, $reportIds) { - if (! empty($reportIds)) { - $builder->whereIn('rf_report_id', $reportIds); - } - - $builder->orWhere(function ($legacyQuery) use ($department) { - $legacyQuery->whereNull('rf_report_id') - ->where('rf_department_id', $department->department_id); - }); - }) - ->whereIn('source_type', self::SPECIAL_SOURCE_TYPES) - ->whereNotNull('rf_medicalhistory_id') - ->get() - ->keyBy('rf_medicalhistory_id'); + $misPatients = $this->misPatientSource->getPatients($user, $status, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots); + $linkedManualPatients = $this->specialPatientSource->getLinkedManualPatientsForPeriod($department, $dateRange); $mergedMisPatients = $misPatients->map(function ($patient) use ($linkedManualPatients) { $linkedManual = $linkedManualPatients->get($patient->MedicalHistoryID); @@ -306,281 +300,13 @@ class UnifiedPatientService ); }); - $manualDtos = $this->mapManualPatients($manualPatients, $dateRange); + $manualDtos = $this->specialPatientSource->getDtos($department, $status, $dateRange, self::SPECIAL_SOURCE_TYPES, $forSnapshots); return UnifiedPatientData::unique($mergedMisPatients->concat($manualDtos)) ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') ->values(); } - private function getMisPatientDtos( - User $user, - string $status, - DateRange $dateRange, - int $branchId, - ?bool $includeCurrent = null, - bool $fillableAuto = false, - bool $forSnapshots = false - ): Collection { - return $this->getMisPatients($user, $status, $dateRange, $branchId, $includeCurrent, $fillableAuto, $forSnapshots) - ->map(fn ($patient) => UnifiedPatientData::fromMisMedicalHistory( - $patient, - (bool) ($patient->is_recipient_today ?? false), - )) - ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') - ->values(); - } - - private function getSpecialPatientDtos( - Department $department, - string $status, - DateRange $dateRange, - bool $forSnapshots = false - ): Collection { - return $this->mapManualPatients( - $this->getManualPatients($department, $status, $dateRange, self::SPECIAL_SOURCE_TYPES, ! $forSnapshots), - $dateRange - ); - } - - private function getMisPatients( - User $user, - string $status, - DateRange $dateRange, - int $branchId, - ?bool $includeCurrent = null, - bool $fillableAuto = false, - bool $forSnapshots = false - ): Collection { - $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); - $includeCurrent = $includeCurrent ?? in_array($status, ['plan', 'emergency'], true); - - return match ($status) { - 'plan', 'emergency' => $this->patientService->getPlanOrEmergencyPatients( - $status, - $isHeadOrAdmin, - $branchId, - $dateRange, - false, - false, - $includeCurrent, - $fillableAuto - ), - 'current' => $this->patientService->getAllPatientsInDepartment( - $isHeadOrAdmin, - $branchId, - $dateRange, - false, - false, - $fillableAuto - ), - 'recipient' => $this->patientService->getPlanOrEmergencyPatients( - null, - $isHeadOrAdmin, - $branchId, - $dateRange, - false, - false, - false, - $fillableAuto - ), - 'outcome' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'without-transferred'), - 'outcome-discharged' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'discharged'), - 'outcome-transferred' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'transferred'), - 'outcome-deceased' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'deceased'), - 'reanimation' => $this->patientService->getReanimationPatients($branchId, $dateRange), - default => collect(), - }; - } - - private function getMisPatientsCount( - User $user, - string $status, - DateRange $dateRange, - int $branchId, - ?bool $includeCurrent = null, - bool $fillableAuto = false - ): int { - $isHeadOrAdmin = $user->isHeadOfDepartment() || $user->isAdmin(); - $includeCurrent = $includeCurrent ?? in_array($status, ['plan', 'emergency'], true); - - return match ($status) { - 'plan', 'emergency' => $includeCurrent - ? $this->patientService->getPatientsCountWithCurrent($status, $isHeadOrAdmin, $branchId, $dateRange) - : $this->patientService->getPlanOrEmergencyPatients( - $status, - $isHeadOrAdmin, - $branchId, - $dateRange, - true, - false, - false, - $fillableAuto - ), - 'current' => $this->patientService->getAllPatientsInDepartment( - $isHeadOrAdmin, - $branchId, - $dateRange, - true, - false, - $fillableAuto - ), - 'recipient' => $this->patientService->getPlanOrEmergencyPatients( - null, - $isHeadOrAdmin, - $branchId, - $dateRange, - true, - false, - false, - $fillableAuto - ), - 'outcome' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'without-transferred', true)->count(), - 'outcome-discharged' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'discharged', true)->count(), - 'outcome-transferred' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'transferred', true)->count(), - 'outcome-deceased' => $this->patientService->getOutcomePatients($branchId, $dateRange, 'deceased', true)->count(), - 'reanimation' => $this->patientService->getReanimationPatients($branchId, $dateRange, true)->count(), - default => 0, - }; - } - - private function getManualPatients( - Department $department, - string $status, - DateRange $dateRange, - ?array $sourceTypes = self::SPECIAL_SOURCE_TYPES, - bool $withOperations = true - ): Collection { - $query = $this->buildManualPatientsQuery($department, $dateRange, $sourceTypes, $withOperations); - - return match ($status) { - 'plan', 'emergency' => $query - ->current() - ->where('patient_kind', $status) - ->get(), - 'current' => $query - ->current() - ->get(), - 'recipient' => $query - ->whereBetween('admitted_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->get(), - 'outcome' => $query - ->whereNotNull('outcome_type') - ->whereIn('outcome_type', ['discharged', 'deceased']) - ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->get(), - 'outcome-discharged', 'outcome-transferred', 'outcome-deceased' => $query - ->where('outcome_type', str_replace('outcome-', '', $status)) - ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->get(), - 'reanimation' => collect(), - default => collect(), - }; - } - - private function getManualPatientsCount( - Department $department, - string $status, - DateRange $dateRange, - ?array $sourceTypes = self::SPECIAL_SOURCE_TYPES - ): int { - $query = $this->buildManualPatientsQuery($department, $dateRange, $sourceTypes, false); - - return match ($status) { - 'plan', 'emergency' => $query - ->current() - ->where('patient_kind', $status) - ->count(), - 'current' => $query - ->current() - ->count(), - 'recipient' => $query - ->whereBetween('admitted_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->count(), - 'outcome' => $query - ->whereNotNull('outcome_type') - ->whereIn('outcome_type', ['discharged', 'deceased']) - ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->count(), - 'outcome-discharged', 'outcome-transferred', 'outcome-deceased' => $query - ->where('outcome_type', str_replace('outcome-', '', $status)) - ->whereBetween('outcome_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->count(), - default => 0, - }; - } - - private function buildManualPatientsQuery( - Department $department, - DateRange $dateRange, - ?array $sourceTypes, - bool $withOperations - ) { - $reportIds = $this->getReportIdsForDepartmentPeriod($department, $dateRange); - - $query = DepartmentPatient::query() - ->where(function ($builder) use ($department, $reportIds) { - if (! empty($reportIds)) { - $builder->whereIn('rf_report_id', $reportIds); - } - - $builder->orWhere(function ($legacyQuery) use ($department) { - $legacyQuery->whereNull('rf_report_id') - ->where('rf_department_id', $department->department_id); - }); - }); - - if ($withOperations) { - $query->with(['operations.serviceMedical']); - } - - if ($sourceTypes !== null) { - $query->whereIn('source_type', $sourceTypes); - } - - return $query; - } - - private function getReportIdsForDepartmentPeriod(Department $department, DateRange $dateRange): array - { - return Report::query() - ->where('rf_department_id', $department->department_id) - ->when( - $dateRange->isOneDay, - fn ($query) => $query->exactPeriod($dateRange->startSql(), $dateRange->endSql()), - fn ($query) => $query->withinPeriod($dateRange->startSql(), $dateRange->endSql()), - ) - ->pluck('report_id') - ->all(); - } - - private function mapManualPatients(Collection $manualPatients, DateRange $dateRange): Collection - { - return $manualPatients - ->map(function (DepartmentPatient $patient) use ($dateRange) { - $operationsRelation = $patient->relationLoaded('operations') - ? $patient->operations - : collect(); - - $operations = $operationsRelation->map(fn ($operation) => [ - 'id' => $operation->department_patient_operation_id, - 'code' => $operation->serviceMedical?->ServiceMedicalCode ?? $operation->service_code, - 'name' => $operation->serviceMedical?->ServiceMedicalName ?? $operation->service_name, - 'startAt' => $operation->started_at?->toIso8601String(), - 'endAt' => $operation->ended_at?->toIso8601String(), - ])->filter(fn ($operation) => $operation['code'] || $operation['name'])->values()->all(); - - return UnifiedPatientData::fromDepartmentPatient( - $patient, - $patient->admitted_at?->betweenIncluded($dateRange->startDate, $dateRange->endDate) ?? false, - $operations, - $this->resolveObservationComment(null, $patient->department_patient_id) - ); - }) - ->sortByDesc(fn (UnifiedPatientData $patient) => $patient->admittedAt ?? '') - ->values(); - } - private function parseScopedStatus(string $status): array { foreach (['mis', 'special'] as $scope) { diff --git a/config/excel.php b/config/excel.php index 9bdfdc9..02aeaf4 100644 --- a/config/excel.php +++ b/config/excel.php @@ -128,7 +128,7 @@ return [ 'enclosure' => '"', 'escape_character' => '\\', 'contiguous' => false, - 'input_encoding' => Csv::GUESS_ENCODING, + 'input_encoding' => class_exists(Csv::class) ? Csv::GUESS_ENCODING : 'UTF-8', ], /* @@ -178,21 +178,21 @@ return [ | */ 'extension_detector' => [ - 'xlsx' => Excel::XLSX, - 'xlsm' => Excel::XLSX, - 'xltx' => Excel::XLSX, - 'xltm' => Excel::XLSX, - 'xls' => Excel::XLS, - 'xlt' => Excel::XLS, - 'ods' => Excel::ODS, - 'ots' => Excel::ODS, - 'slk' => Excel::SLK, - 'xml' => Excel::XML, - 'gnumeric' => Excel::GNUMERIC, - 'htm' => Excel::HTML, - 'html' => Excel::HTML, - 'csv' => Excel::CSV, - 'tsv' => Excel::TSV, + 'xlsx' => 'Xlsx', + 'xlsm' => 'Xlsx', + 'xltx' => 'Xlsx', + 'xltm' => 'Xlsx', + 'xls' => 'Xls', + 'xlt' => 'Xls', + 'ods' => 'Ods', + 'ots' => 'Ods', + 'slk' => 'Slk', + 'xml' => 'Xml', + 'gnumeric' => 'Gnumeric', + 'htm' => 'Html', + 'html' => 'Html', + 'csv' => 'Csv', + 'tsv' => 'Csv', /* |-------------------------------------------------------------------------- @@ -203,7 +203,7 @@ return [ | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF | */ - 'pdf' => Excel::DOMPDF, + 'pdf' => 'Dompdf', ], /* diff --git a/config/logging.php b/config/logging.php index 9e998a4..9b160d4 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,14 @@ return [ 'replace_placeholders' => true, ], + 'reports_audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/reports_audit.log'), + 'level' => env('LOG_LEVEL', 'info'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/reports.php b/config/reports.php new file mode 100644 index 0000000..9ec6f8e --- /dev/null +++ b/config/reports.php @@ -0,0 +1,10 @@ + [ + 'report_types' => array_values(array_filter( + array_map('trim', explode(',', (string) env('REPORTS_USE_NEW_ARCH_TYPES', ''))) + )), + 'compare_before_cutover' => (bool) env('REPORTS_COMPARE_BEFORE_CUTOVER', true), + ], +]; diff --git a/docs/adr/0001-report-domain-strangler.md b/docs/adr/0001-report-domain-strangler.md new file mode 100644 index 0000000..5423328 --- /dev/null +++ b/docs/adr/0001-report-domain-strangler.md @@ -0,0 +1,19 @@ +# ADR 0001: Report Domain Strangler + +## Status +Accepted + +## Context +The first report flow in the medical reporting system has grown into a large Laravel service that mixes orchestration, replica reads, calculations, persistence, snapshots, and post-processing. + +## Decision +We introduce `Domain/Reports`, `Application/Reports`, and `Infrastructure/Reports` alongside the legacy implementation. The first migration slice covers only the save path and auto-fill path. Read-side statistics remain on the legacy path. + +The new save flow is orchestrated by `GenerateReportUseCase` and persists through `EloquentReportRepository`. Transitional adapters to `ReportService` and `SnapshotService` are allowed until formulas and data sources are moved into Domain-native calculators and sources. + +Feature rollout is controlled by `config('reports.use_new_arch.report_types')`. Each new-path execution writes an audit record to `reports_audit` with comparison status and diff payload. + +## Consequences +- Legacy behavior remains available as a fallback. +- New code can be unit-tested without framework imports in Domain/Application DTOs. +- Future reports can reuse the same orchestration, repository contract, and audit pipeline. diff --git a/docs/report-domain-migration-handoff.md b/docs/report-domain-migration-handoff.md new file mode 100644 index 0000000..b79a3c4 --- /dev/null +++ b/docs/report-domain-migration-handoff.md @@ -0,0 +1,170 @@ +# Handoff: миграция отчётов на Domain-архитектуру + +Дата: 2026-04-26 + +## Что уже сделано + +### 1. Базовый каркас Domain / Application / Infrastructure +Добавлены новые слои: + +- `app/Domain/Reports` +- `app/Application/Reports` +- `app/Infrastructure/Reports` + +Ключевые элементы: + +- `Domain/Reports/ValueObjects/MetrikaConfig` +- `Domain/Reports/Models/ReportSnapshot` +- `Domain/Reports/Contracts/*` +- `Application/Reports/DTO/*` +- `Application/Reports/GenerateReportUseCase` +- `Application/Reports/CompareLegacyAndNewReportUseCase` +- `Infrastructure/Reports/Repositories/EloquentReportRepository` +- `Infrastructure/Reports/Adapters/LegacyReportServiceAdapter` +- `Infrastructure/Reports/Logging/ReportsAuditLogger` + +### 2. Save-path уже переведён на strangler-схему +Новый save-flow работает рядом со старым кодом: + +- старый `ReportService` не удалён +- новый путь включается через feature-flag +- legacy остаётся fallback +- сравнение old/new идёт через comparator и audit logger + +Точки входа уже знают про новый flow: + +- `app/Http/Controllers/Web/ReportController.php` +- `app/Http/Controllers/Api/ReportController.php` +- `app/Services/AutoReportService.php` + +### 3. Вынесена существенная часть persistence и orchestration +Из `ReportService` уже выделены: + +- `Infrastructure/Reports/Services/ReportStorageService` +- `Infrastructure/Reports/Services/ReportMetricsFinalizer` +- `Infrastructure/Reports/Services/CalculatedMetricsSynchronizer` +- `Infrastructure/Reports/Services/SnapshotPersistenceService` +- `Infrastructure/Reports/Services/AutoFillReportPayloadBuilder` + +### 4. Вынесены источники данных +Сейчас выделены отдельные источники: + +- `Infrastructure/Reports/Sources/MisPatientSource` +- `Infrastructure/Reports/Sources/SpecialPatientSource` +- `Infrastructure/Reports/Sources/SnapshotPatientSource` +- `Infrastructure/Reports/Sources/MisClinicalDataSource` +- `Infrastructure/Reports/Sources/LegacyAutoFillPatientSource` + +### 5. Вынесены Domain-calculators +Сейчас в Domain уже есть: + +- `BedDaysCalculator` +- `PreoperativeDaysCalculator` +- `DepartmentLoadCalculator` + +### 6. Read-path пациентов тоже вынесен +Добавлены: + +- `Infrastructure/Reports/Services/ReportPatientsReadService` +- `Infrastructure/Reports/Services/ReportReadContextResolver` + +Теперь `ReportService` уже делегирует туда: + +- `getPatientsByStatus` +- `getPatientsCountByStatus` +- `getPatientsCountsMap` +- `getPatientsFromSnapshots` + +Это важный шаг: `ReportService` постепенно становится legacy facade, а не местом, где живёт вся логика. + +## Что ещё специально НЕ сделано + +- старый код не удалён +- полный cutover на новую архитектуру не выполнен +- `getReportStatistics` пока ещё остаётся внутри `ReportService` +- часть legacy read/statistics логики ещё не вынесена в отдельный infrastructure/application слой +- integration tests на SQLite здесь не гонялись, потому что в окружении нет `pdo_sqlite` + +## Проверки и тесты + +Проходит: + +```bash +php artisan test tests/Unit/Reports +``` + +Сейчас зелёные unit-тесты для: + +- `MetrikaConfig` +- `ReportSnapshot` +- `GenerateReportUseCase` +- `CompareLegacyAndNewReportUseCase` +- `ReportInputFactory` +- `ReportPatientsReadService` +- domain calculators + +## Важный контекст по БД и тестам + +Основное приложение работает с `pgsql` из `.env`, но тесты не обязаны использовать это окружение. + +Важно: + +- в `phpunit.xml` тестовый runtime жёстко переключён на `sqlite` +- в текущем окружении отсутствует `pdo_sqlite` +- поэтому feature/integration тесты здесь не были полноценно прогнаны + +Если завтра нужно будет запускать integration-тесты на PostgreSQL, надо отдельно переводить test-конфиг. + +## Дополнительные важные замечания + +### 1. В worktree есть изменённые файлы +Нужно быть осторожным и не перетирать чужие правки. + +Особенно внимательно смотреть на: + +- `app/Http/Controllers/Api/ReportController.php` +- `app/Services/ReportService.php` +- `app/Services/Reports/PatientQueryBuilder.php` + +### 2. Есть защитный фикс в конфиге Excel +Ранее был добавлен безопасный guard в `config/excel.php`, чтобы bootstrap не падал, если в окружении нет нужных excel-классов. + +### 3. Комментарии в новом слое частично русифицированы +Русифицированы PHPDoc в основных новых infrastructure/application классах миграции. +При желании можно отдельным проходом довести до конца все новые DTO, адаптеры и domain models. + +## Самый логичный следующий шаг + +Следующая итерация: + +1. Вынести `getReportStatistics` из `ReportService` в отдельный read-service. +2. Затем вынести snapshot/replica statistics orchestration в новый infrastructure/application слой. +3. После этого ещё сильнее сузить `ReportService` до legacy facade. + +Рекомендуемое направление: + +- создать что-то вроде `Infrastructure/Reports/Services/ReportStatisticsReadService` +- перевести туда: + - `getStatisticsFromSnapshots` + - `getStatisticsFromReplica` + - вспомогательные read-context части, если они ещё не покрыты `ReportReadContextResolver` + +## Если нужно быстро продолжить завтра + +Сначала открыть: + +- `docs/report-domain-migration-handoff.md` +- `app/Services/ReportService.php` +- `app/Infrastructure/Reports/Services/ReportPatientsReadService.php` +- `app/Infrastructure/Reports/Services/ReportReadContextResolver.php` + +Потом проверить: + +```bash +php artisan test tests/Unit/Reports +``` + +И дальше брать следующий шаг: + +- перенос `getReportStatistics` в отдельный сервис + diff --git a/package-lock.json b/package-lock.json index 5a75ca7..e4716f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2119,6 +2119,7 @@ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -2194,6 +2195,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2247,6 +2249,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3188,6 +3191,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -3669,6 +3673,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4026,6 +4031,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", diff --git a/tests/Feature/AutoFillReportsTest.php b/tests/Feature/AutoFillReportsTest.php index 7f8ae3f..84cd99b 100644 --- a/tests/Feature/AutoFillReportsTest.php +++ b/tests/Feature/AutoFillReportsTest.php @@ -1,6 +1,7 @@ andReturn(new \App\Models\Report); - $service = new AutoReportService($reportService, app(DateRangeService::class)); + $service = new AutoReportService( + $reportService, + app(DateRangeService::class), + \Mockery::mock(ReportSavePathService::class), + ); expect($service->createReportForDate($user, $department, autoFillRange(), false))->toBeTrue(); }); @@ -343,7 +348,11 @@ it('force recreation removes previous report scoped data before storing a new au ->once() ->andReturn(new \App\Models\Report); - $service = new AutoReportService($reportService, app(DateRangeService::class)); + $service = new AutoReportService( + $reportService, + app(DateRangeService::class), + \Mockery::mock(ReportSavePathService::class), + ); expect($service->createReportForDate($user, $department, autoFillRange(), true))->toBeTrue() ->and(DB::table('reports')->where('report_id', 55)->exists())->toBeFalse() diff --git a/tests/Feature/Reports/EloquentReportRepositoryTest.php b/tests/Feature/Reports/EloquentReportRepositoryTest.php new file mode 100644 index 0000000..730960d --- /dev/null +++ b/tests/Feature/Reports/EloquentReportRepositoryTest.php @@ -0,0 +1,150 @@ +id(); + $table->string('name')->nullable(); + $table->string('login')->nullable(); + $table->string('password')->nullable(); + $table->timestamps(); + }); + + Schema::create('departments', function (Blueprint $table) { + $table->id('department_id'); + $table->string('name_full')->nullable(); + $table->string('name_short')->nullable(); + $table->integer('rf_mis_department_id')->nullable(); + $table->integer('rf_department_type')->nullable(); + }); + + Schema::create('department_metrika_defaults', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('rf_department_id'); + $table->unsignedBigInteger('rf_metrika_item_id'); + $table->string('value')->nullable(); + }); + + Schema::create('reports', function (Blueprint $table) { + $table->id('report_id'); + $table->dateTime('created_at'); + $table->dateTime('sent_at')->nullable(); + $table->unsignedBigInteger('rf_department_id'); + $table->unsignedBigInteger('rf_user_id')->nullable(); + $table->unsignedBigInteger('rf_lpudoctor_id')->nullable(); + $table->dateTime('period_start')->nullable(); + $table->dateTime('period_end')->nullable(); + $table->string('status')->default('draft'); + }); + + Schema::create('metrika_results', function (Blueprint $table) { + $table->id('metrika_result_id'); + $table->unsignedBigInteger('rf_report_id'); + $table->unsignedBigInteger('rf_metrika_item_id'); + $table->string('value')->nullable(); + }); + + Schema::create('observation_patients', function (Blueprint $table) { + $table->id('observation_patient_id'); + $table->unsignedBigInteger('rf_report_id')->nullable(); + $table->unsignedBigInteger('rf_department_id')->nullable(); + $table->unsignedBigInteger('rf_medicalhistory_id')->nullable(); + $table->unsignedBigInteger('rf_department_patient_id')->nullable(); + $table->text('comment')->nullable(); + }); + + Schema::create('unwanted_events', function (Blueprint $table) { + $table->id('unwanted_event_id'); + $table->unsignedBigInteger('rf_report_id')->nullable(); + $table->text('comment')->nullable(); + $table->string('title')->nullable(); + $table->boolean('is_visible')->default(true); + $table->timestamps(); + }); + + DB::table('users')->insert([ + 'id' => 15, + 'name' => 'Doctor', + 'login' => 'doc', + 'password' => 'secret', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('departments')->insert([ + 'department_id' => 10, + 'name_full' => 'Department', + 'name_short' => 'Dept', + 'rf_mis_department_id' => 100, + ]); + + DB::table('department_metrika_defaults')->insert([ + 'rf_department_id' => 10, + 'rf_metrika_item_id' => 1, + 'value' => '30', + ]); +}); + +afterEach(function () { + \Mockery::close(); +}); + +it('saves report snapshot idempotently through eloquent repository', function () { + $adapter = \Mockery::mock(LegacyReportServiceAdapter::class); + $adapter->shouldReceive('prepareMemoryForHeavySave')->twice(); + $adapter->shouldReceive('createPatientSnapshots')->twice(); + $adapter->shouldReceive('syncCalculatedMetrics')->twice(); + $adapter->shouldReceive('saveLethalMetricFromSnapshots')->twice(); + $adapter->shouldReceive('clearCacheAfterReportCreation')->twice(); + + $repository = new EloquentReportRepository($adapter); + + $snapshot = new ReportSnapshot( + departmentId: 10, + userId: 5015, + actorUserId: 15, + periodStart: new DateTimeImmutable('2026-04-08 06:00:00'), + periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'), + status: 'draft', + metrics: [4 => 11], + observationPatients: [['medical_history_id' => 100, 'comment' => 'watch']], + unwantedEvents: [['title' => 'event', 'comment' => 'test', 'is_visible' => true]], + ); + + $first = $repository->save($snapshot); + $second = $repository->save(new ReportSnapshot( + departmentId: 10, + userId: 5015, + actorUserId: 15, + periodStart: new DateTimeImmutable('2026-04-08 06:00:00'), + periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'), + status: 'draft', + metrics: [4 => 12], + observationPatients: [['medical_history_id' => 100, 'comment' => 'watch-2']], + unwantedEvents: [['title' => 'event-2', 'comment' => 'test-2', 'is_visible' => true]], + reportId: $first->reportId, + )); + + expect($first->reportId)->toBe($second->reportId) + ->and(DB::table('reports')->count())->toBe(1) + ->and(DB::table('metrika_results')->where('rf_report_id', $first->reportId)->where('rf_metrika_item_id', 4)->value('value'))->toBe('12') + ->and(DB::table('observation_patients')->where('rf_report_id', $first->reportId)->value('comment'))->toBe('watch-2'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..044de62 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ pest()->extend(Tests\TestCase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', 'Unit'); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/Reports/BedDaysCalculatorTest.php b/tests/Unit/Reports/BedDaysCalculatorTest.php new file mode 100644 index 0000000..c212bc1 --- /dev/null +++ b/tests/Unit/Reports/BedDaysCalculatorTest.php @@ -0,0 +1,38 @@ +calculate([ + new StayInterval( + startAt: new DateTimeImmutable('2026-04-01 10:00:00'), + endAt: new DateTimeImmutable('2026-04-04 09:00:00'), + ), + new StayInterval( + startAt: new DateTimeImmutable('2026-04-05 10:00:00'), + endAt: new DateTimeImmutable('2026-04-07 09:00:00'), + ), + ]); + + expect($result->total)->toBe(5) + ->and($result->count)->toBe(2) + ->and($result->average)->toBe(2.5); +}); + +it('ignores invalid bed day intervals', function () { + $calculator = new BedDaysCalculator; + + $result = $calculator->calculate([ + new StayInterval( + startAt: new DateTimeImmutable('2026-04-04 10:00:00'), + endAt: new DateTimeImmutable('2026-04-01 09:00:00'), + ), + ]); + + expect($result->total)->toBe(0) + ->and($result->count)->toBe(0) + ->and($result->average)->toBe(0.0); +}); diff --git a/tests/Unit/Reports/CompareLegacyAndNewReportUseCaseTest.php b/tests/Unit/Reports/CompareLegacyAndNewReportUseCaseTest.php new file mode 100644 index 0000000..0e73a66 --- /dev/null +++ b/tests/Unit/Reports/CompareLegacyAndNewReportUseCaseTest.php @@ -0,0 +1,47 @@ + 10, + 'userId' => 5015, + 'dates' => [1744063200, 1744149600], + 'metrics' => ['metrika_item_4' => 11], + 'observationPatients' => [], + 'unwantedEvents' => [], + ], + persistedReportId: 55, + ); + + $snapshot = new ReportSnapshot( + departmentId: 10, + userId: 5015, + actorUserId: 15, + periodStart: new DateTimeImmutable('2026-04-08 06:00:00'), + periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'), + metrics: [4 => 11], + ); + + $repository = \Mockery::mock(ReportRepository::class); + $repository->shouldReceive('findSnapshot')->once()->with(55)->andReturn($snapshot); + + $adapter = \Mockery::mock(LegacyReportServiceAdapter::class); + $adapter->shouldReceive('buildSnapshotFromInput')->once()->with($input)->andReturn($snapshot); + + $result = (new CompareLegacyAndNewReportUseCase($repository, $adapter))->handle($input); + + expect($result->status)->toBe('matched') + ->and($result->diff)->toBe([]); +}); diff --git a/tests/Unit/Reports/DepartmentLoadCalculatorTest.php b/tests/Unit/Reports/DepartmentLoadCalculatorTest.php new file mode 100644 index 0000000..cadbf9b --- /dev/null +++ b/tests/Unit/Reports/DepartmentLoadCalculatorTest.php @@ -0,0 +1,15 @@ +calculate(27, 30))->toBe(90); +}); + +it('returns zero when beds count is zero', function () { + $calculator = new DepartmentLoadCalculator; + + expect($calculator->calculate(27, 0))->toBe(0); +}); diff --git a/tests/Unit/Reports/GenerateReportUseCaseTest.php b/tests/Unit/Reports/GenerateReportUseCaseTest.php new file mode 100644 index 0000000..6b5f30e --- /dev/null +++ b/tests/Unit/Reports/GenerateReportUseCaseTest.php @@ -0,0 +1,92 @@ + [ + 'departmentId' => $context->departmentId, + 'userId' => $context->userId, + 'dates' => [$context->periodStart->getTimestamp(), $context->periodEnd->getTimestamp()], + 'status' => 'submitted', + 'metrics' => ['metrika_item_4' => 11], + 'observationPatients' => [], + 'unwantedEvents' => [], + ], + ]); + } + }; + + $snapshot = new ReportSnapshot( + departmentId: 10, + userId: 5015, + actorUserId: 15, + periodStart: new DateTimeImmutable('2026-04-08 06:00:00'), + periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'), + status: 'submitted', + autoFill: true, + metrics: [4 => 11], + ); + + $repository = \Mockery::mock(ReportRepository::class); + $repository->shouldReceive('save') + ->once() + ->andReturn(new SavedReportResult(88, $snapshot)); + + $repository->shouldReceive('findSnapshot') + ->once() + ->with(88) + ->andReturn($snapshot); + + $adapter = \Mockery::mock(LegacyReportServiceAdapter::class); + $adapter->shouldReceive('buildSnapshotFromInput') + ->once() + ->andReturn($snapshot); + + $comparator = new CompareLegacyAndNewReportUseCase($repository, $adapter); + + $auditLogger = \Mockery::mock(AuditLogger::class); + $auditLogger->shouldReceive('logComparison')->once(); + + $useCase = new GenerateReportUseCase( + reportRepository: $repository, + auditLogger: $auditLogger, + comparator: $comparator, + patientSource: $patientSource, + calculators: [], + compareBeforeCutover: true, + ); + + $result = $useCase->handle($input); + + expect($result->reportId)->toBe(88) + ->and($result->usedNewArchitecture)->toBeTrue() + ->and($result->comparison?->status)->toBe('matched') + ->and($result->comparison?->diff)->toBe([]) + ->and($result->comparison?->reportId)->toBe(88); +}); diff --git a/tests/Unit/Reports/MetrikaConfigTest.php b/tests/Unit/Reports/MetrikaConfigTest.php new file mode 100644 index 0000000..7d53ce7 --- /dev/null +++ b/tests/Unit/Reports/MetrikaConfigTest.php @@ -0,0 +1,28 @@ + 7, + 4 => 11, + 'invalid' => 100, + '15' => 3, + ]); + + expect($normalized)->toBe([ + 4 => 11, + 12 => 7, + 15 => 3, + ]); +}); + +it('converts normalized metrics back to payload keys', function () { + expect(MetrikaConfig::toPayloadMetrics([ + 4 => 11, + 12 => 7, + ]))->toBe([ + 'metrika_item_4' => 11, + 'metrika_item_12' => 7, + ]); +}); diff --git a/tests/Unit/Reports/PreoperativeDaysCalculatorTest.php b/tests/Unit/Reports/PreoperativeDaysCalculatorTest.php new file mode 100644 index 0000000..2962003 --- /dev/null +++ b/tests/Unit/Reports/PreoperativeDaysCalculatorTest.php @@ -0,0 +1,38 @@ +calculate([ + new OperationInterval( + admittedAt: new DateTimeImmutable('2026-04-01 10:00:00'), + operationAt: new DateTimeImmutable('2026-04-03 09:00:00'), + ), + new OperationInterval( + admittedAt: new DateTimeImmutable('2026-04-05 10:00:00'), + operationAt: new DateTimeImmutable('2026-04-06 09:00:00'), + ), + ]); + + expect($result->total)->toBe(3) + ->and($result->count)->toBe(2) + ->and($result->average)->toBe(1.5); +}); + +it('ignores invalid preoperative intervals', function () { + $calculator = new PreoperativeDaysCalculator; + + $result = $calculator->calculate([ + new OperationInterval( + admittedAt: new DateTimeImmutable('2026-04-03 10:00:00'), + operationAt: new DateTimeImmutable('2026-04-01 09:00:00'), + ), + ]); + + expect($result->total)->toBe(0) + ->and($result->count)->toBe(0) + ->and($result->average)->toBe(0.0); +}); diff --git a/tests/Unit/Reports/ReportInputFactoryTest.php b/tests/Unit/Reports/ReportInputFactoryTest.php new file mode 100644 index 0000000..e99be19 --- /dev/null +++ b/tests/Unit/Reports/ReportInputFactoryTest.php @@ -0,0 +1,58 @@ +id = 15; + + $factory = new ReportInputFactory(app(DateRangeService::class)); + + $input = $factory->forManualSave($user, [ + 'departmentId' => 10, + 'userId' => 5015, + 'dates' => [1744063200, 1744149600], + 'metrics' => ['metrika_item_4' => 11], + 'observationPatients' => [['id' => 100]], + 'unwantedEvents' => [['title' => 'A']], + 'status' => 'draft', + 'reportId' => 55, + ]); + + expect($input->departmentId)->toBe(10) + ->and($input->userId)->toBe(5015) + ->and($input->actorUserId)->toBe(15) + ->and($input->reportId)->toBe(55) + ->and($input->metrics)->toBe(['metrika_item_4' => 11]) + ->and($input->rawPayload['actorUserId'])->toBe(15); +}); + +it('builds auto fill generate report input from scoped user and date range', function () { + $user = new User; + $user->id = 15; + $user->rf_lpudoctor_id = 5015; + + $department = new Department; + $department->department_id = 10; + + $dateRange = new DateRange( + startDate: Carbon::parse('2026-04-08 06:00:00', 'Asia/Yakutsk'), + endDate: Carbon::parse('2026-04-09 06:00:00', 'Asia/Yakutsk'), + startDateRaw: '2026-04-08 06:00:00', + endDateRaw: '2026-04-09 06:00:00', + isOneDay: true, + ); + + $factory = new ReportInputFactory(app(DateRangeService::class)); + $input = $factory->forAutoFill($user, $department, $dateRange); + + expect($input->autoFill)->toBeTrue() + ->and($input->status)->toBe('submitted') + ->and($input->departmentId)->toBe(10) + ->and($input->userId)->toBe(5015); +}); diff --git a/tests/Unit/Reports/ReportPatientsReadServiceTest.php b/tests/Unit/Reports/ReportPatientsReadServiceTest.php new file mode 100644 index 0000000..957e5ca --- /dev/null +++ b/tests/Unit/Reports/ReportPatientsReadServiceTest.php @@ -0,0 +1,148 @@ +forceFill([ + 'department_id' => 100, + 'rf_mis_department_id' => 200, + ]); + $user = \Mockery::mock(User::class); + $dateRange = new DateRange( + Carbon::parse('2026-04-08 06:00:00'), + Carbon::parse('2026-04-09 06:00:00'), + '2026-04-08 06:00:00', + '2026-04-09 06:00:00', + true, + ); + + $snapshotPatients = collect([ + (object) ['id' => 'mis:10', 'sourceType' => 'mis'], + (object) ['id' => 'manual:501', 'sourceType' => 'manual'], + ]); + + $unifiedPatientService = \Mockery::mock(UnifiedPatientService::class); + $snapshotService = \Mockery::mock(SnapshotService::class); + $contextResolver = \Mockery::mock(ReportReadContextResolver::class); + + $contextResolver->shouldReceive('resolveBranchId') + ->once() + ->with($department) + ->andReturn(10); + $contextResolver->shouldReceive('shouldUseReplicaForLiveStatus') + ->once() + ->with($user, 'plan', $dateRange) + ->andReturn(false); + $contextResolver->shouldReceive('shouldUseSnapshots') + ->once() + ->with($department, $dateRange, false) + ->andReturn(true); + $contextResolver->shouldReceive('getReportsForDateRange') + ->once() + ->with(100, $dateRange) + ->andReturn(collect([(object) ['report_id' => 91]])); + $contextResolver->shouldReceive('getRecipientReportIds') + ->once() + ->with([91]) + ->andReturn([91]); + + $snapshotService->shouldReceive('getPatientsFromOneDayCurrentSnapshots') + ->once() + ->with('plan', [91], false, [91]) + ->andReturn($snapshotPatients); + + $service = new ReportPatientsReadService( + unifiedPatientService: $unifiedPatientService, + snapshotService: $snapshotService, + contextResolver: $contextResolver, + ); + + $patientIds = $service->getPatientsByStatus($department, $user, 'mis-plan', $dateRange, true); + + expect($patientIds)->toBeInstanceOf(Collection::class) + ->and($patientIds->all())->toBe(['mis:10']); +}); + +it('always reads reanimation patients from replica sources', function () { + $department = (new Department())->forceFill([ + 'department_id' => 100, + 'rf_mis_department_id' => 200, + ]); + $user = \Mockery::mock(User::class); + $dateRange = new DateRange( + Carbon::parse('2026-04-08 06:00:00'), + Carbon::parse('2026-04-09 06:00:00'), + '2026-04-08 06:00:00', + '2026-04-09 06:00:00', + true, + ); + + $expected = collect([(object) ['id' => 'mis:55']]); + + $unifiedPatientService = \Mockery::mock(UnifiedPatientService::class); + $snapshotService = \Mockery::mock(SnapshotService::class); + $contextResolver = \Mockery::mock(ReportReadContextResolver::class); + + $contextResolver->shouldReceive('resolveBranchId') + ->once() + ->with($department) + ->andReturn(10); + + $unifiedPatientService->shouldReceive('getLivePatientsByStatus') + ->once() + ->with($department, $user, 'reanimation', $dateRange, 10, false, true) + ->andReturn($expected); + + $service = new ReportPatientsReadService( + unifiedPatientService: $unifiedPatientService, + snapshotService: $snapshotService, + contextResolver: $contextResolver, + ); + + expect($service->getPatientsByStatus($department, $user, 'reanimation', $dateRange))->toBe($expected); +}); + +it('counts scoped replica patients through unified patient service', function () { + $department = (new Department())->forceFill([ + 'department_id' => 100, + 'rf_mis_department_id' => 200, + ]); + $user = \Mockery::mock(User::class); + $dateRange = new DateRange( + Carbon::parse('2026-04-08 06:00:00'), + Carbon::parse('2026-04-09 06:00:00'), + '2026-04-08 06:00:00', + '2026-04-09 06:00:00', + true, + ); + + $unifiedPatientService = \Mockery::mock(UnifiedPatientService::class); + $snapshotService = \Mockery::mock(SnapshotService::class); + $contextResolver = \Mockery::mock(ReportReadContextResolver::class); + + $contextResolver->shouldReceive('resolveBranchId') + ->once() + ->with($department) + ->andReturn(10); + + $unifiedPatientService->shouldReceive('getLivePatientCountByStatus') + ->once() + ->with($department, $user, 'special-plan', $dateRange, 10, true) + ->andReturn(3); + + $service = new ReportPatientsReadService( + unifiedPatientService: $unifiedPatientService, + snapshotService: $snapshotService, + contextResolver: $contextResolver, + ); + + expect($service->getPatientsCountByStatus($department, $user, 'special-plan', $dateRange))->toBe(3); +}); diff --git a/tests/Unit/Reports/ReportSnapshotTest.php b/tests/Unit/Reports/ReportSnapshotTest.php new file mode 100644 index 0000000..c72db76 --- /dev/null +++ b/tests/Unit/Reports/ReportSnapshotTest.php @@ -0,0 +1,36 @@ + 7, + 4 => 11, + ], + observationPatients: [['medical_history_id' => 100]], + unwantedEvents: [['title' => 'Event']], + ); + + expect($snapshot->normalizedMetrics())->toBe([ + 4 => 11, + 12 => 7, + ])->and($snapshot->toComparableArray()['auto_fill'])->toBeTrue(); +}); + +it('rejects invalid period ranges', function () { + new ReportSnapshot( + departmentId: 10, + userId: 5015, + actorUserId: 15, + periodStart: new DateTimeImmutable('2026-04-09 06:00:00'), + periodEnd: new DateTimeImmutable('2026-04-08 06:00:00'), + ); +})->throws(InvalidArgumentException::class);