Перевод на доменную архитектуру

This commit is contained in:
brusnitsyn
2026-04-26 23:37:50 +09:00
parent 75ca01ffd8
commit f107ebd167
70 changed files with 4656 additions and 2070 deletions

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\MisMedicalHistory;
use App\Models\MisStationarBranch;
use App\Models\User;
use App\Services\DateRange;
use App\Services\PatientService;
use App\Services\UnifiedPatientService;
use Illuminate\Support\Facades\DB;
class AutoFillReportPayloadBuilder
{
public function __construct(
private readonly UnifiedPatientService $unifiedPatientService,
private readonly PatientService $patientService,
private readonly CalculatedMetricsSynchronizer $calculatedMetricsSynchronizer,
) {}
public function build(User $user, Department $department, DateRange $dateRange): array
{
$branchId = $this->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');
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\DepartmentPatientOperation;
use App\Models\MedicalHistorySnapshot;
use App\Models\MisStationarBranch;
use App\Models\ObservationPatient;
use App\Models\Report;
use App\Models\UnwantedEvent;
use App\Models\User;
use App\Services\DateRange;
use App\Services\DateRangeService;
use App\Services\PatientService;
class CalculatedMetricsSynchronizer
{
public function __construct(
private readonly DateRangeService $dateRangeService,
private readonly PatientService $patientService,
private readonly ReportStorageService $reportStorageService,
) {}
/**
* @param array<string, mixed> $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];
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Domain\Reports\Calculators\BedDaysCalculator;
use App\Domain\Reports\Calculators\DepartmentLoadCalculator;
use App\Domain\Reports\Calculators\PreoperativeDaysCalculator;
use App\Domain\Reports\Models\OperationInterval;
use App\Domain\Reports\Models\StayInterval;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\MedicalHistorySnapshot;
use App\Models\Report;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
class ReportMetricsFinalizer
{
public function __construct(
private readonly BedDaysCalculator $bedDaysCalculator,
private readonly PreoperativeDaysCalculator $preoperativeDaysCalculator,
private readonly DepartmentLoadCalculator $departmentLoadCalculator,
private readonly ReportStorageService $reportStorageService,
) {}
public function finalize(Report $report): void
{
$this->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<int, StayInterval>
*/
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<int, OperationInterval>
*/
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;
}
}

View File

@@ -0,0 +1,398 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Data\UnifiedPatientData;
use App\Models\Department;
use App\Models\MedicalHistorySnapshot;
use App\Models\User;
use App\Services\DateRange;
use App\Services\SnapshotService;
use App\Services\UnifiedPatientService;
use Illuminate\Support\Collection;
/**
* Сервис чтения пациентских выборок для отчётов.
*
* Он решает, откуда обслуживать запрос: из submitted-снапшотов или из live-реплики,
* и при этом сохраняет API близким к старым методам ReportService для
* постепенной strangler-миграции.
*/
class ReportPatientsReadService
{
public function __construct(
private readonly UnifiedPatientService $unifiedPatientService,
private readonly SnapshotService $snapshotService,
private readonly ReportReadContextResolver $contextResolver,
) {}
/**
* Получить пациентов отчёта по запрошенному статусу и области источника.
*/
public function getPatientsByStatus(
Department $department,
User $user,
string $status,
DateRange $dateRange,
bool $onlyIds = false,
bool $beforeCreate = false,
?bool $includeCurrentPatients = null
) {
[$baseStatus, $sourceScope] = $this->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'];
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Models\Department;
use App\Models\MisStationarBranch;
use App\Models\Report;
use App\Models\User;
use App\Services\DateRange;
use Illuminate\Support\Collection;
/**
* Разрешает контекст отчётного периода, необходимый для read-side сервисов.
*
* Класс хранит в одном месте правила поиска отчётов по периоду и решения
* snapshot-vs-replica, чтобы не дублировать их по контроллерам и сервисам.
*/
class ReportReadContextResolver
{
/**
* Определить MIS branch id для отчётного отделения.
*/
public function resolveBranchId(Department $department): ?int
{
return MisStationarBranch::query()
->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<int, Report>
*/
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<int, int> $reportIds
* @return array<int, int>
*/
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();
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Domain\Reports\Models\ReportSnapshot;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\MetrikaResult;
use App\Models\ObservationPatient;
use App\Models\Report;
use App\Models\UnwantedEvent;
use App\Models\User;
class ReportStorageService
{
public function createOrUpdateReport(ReportSnapshot $snapshot, User $actor): Report
{
$reportData = [
'rf_department_id' => $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));
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Infrastructure\Reports\Services;
use App\Data\UnifiedPatientData;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\MedicalHistorySnapshot;
use App\Models\MetrikaResult;
use App\Models\Report;
use Illuminate\Support\Collection;
class SnapshotPersistenceService
{
/**
* Сохранить метрики, полученные из снапшотов, с идемпотентным upsert.
*
* @param array<int, int|float|string|null> $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<string, int>
*/
public function snapshotMetricMap(): array
{
return [
'plan' => MetrikaConfig::PLAN,
'emergency' => MetrikaConfig::EMERGENCY,
'discharged' => MetrikaConfig::DISCHARGED,
'transferred' => MetrikaConfig::TRANSFERRED,
];
}
}