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

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,72 @@
<?php
namespace App\Application\Reports;
use App\Application\Reports\DTO\GenerateReportInput;
use App\Application\Reports\DTO\ReportComparisonResult;
use App\Domain\Reports\Contracts\ReportRepository;
use App\Infrastructure\Reports\Adapters\LegacyReportServiceAdapter;
use RuntimeException;
final readonly class CompareLegacyAndNewReportUseCase
{
public function __construct(
private ReportRepository $reportRepository,
private LegacyReportServiceAdapter $legacyAdapter,
) {}
public function handle(GenerateReportInput $input): ReportComparisonResult
{
$startedAt = microtime(true);
$expected = $this->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<string, mixed> $expected
* @param array<string, mixed> $actual
* @return array<string, mixed>
*/
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;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Application\Reports\DTO;
use App\Domain\Reports\Models\ReportContext;
use App\Domain\Reports\Models\ReportSnapshot;
use DateTimeImmutable;
final readonly class GenerateReportInput
{
/**
* @param array<int|string, int|float|string|null> $metrics
* @param array<int, array<string, mixed>> $observationPatients
* @param array<int, array<string, mixed>> $unwantedEvents
* @param array<string, mixed>|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<string, mixed> $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,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Application\Reports\DTO;
final readonly class GenerateReportResult
{
public function __construct(
public int $reportId,
public string $path,
public bool $usedNewArchitecture,
public ?ReportComparisonResult $comparison = null,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Application\Reports\DTO;
final readonly class ReportComparisonResult
{
/**
* @param array<string, mixed> $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,
) {}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Application\Reports;
use App\Application\Reports\DTO\GenerateReportInput;
use App\Application\Reports\DTO\GenerateReportResult;
use App\Application\Reports\DTO\ReportComparisonResult;
use App\Domain\Reports\Contracts\AuditLogger;
use App\Domain\Reports\Contracts\MetricCalculator;
use App\Domain\Reports\Contracts\PatientSource;
use App\Domain\Reports\Contracts\ReportRepository;
use App\Domain\Reports\Models\PatientCollection;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use DateTimeImmutable;
final readonly class GenerateReportUseCase
{
/**
* @param iterable<MetricCalculator> $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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Application\Reports;
final class ReportFlowDecider
{
public function shouldUseNewArchitecture(string $reportType = 'daily'): bool
{
$enabledTypes = config('reports.use_new_arch.report_types', []);
return in_array($reportType, $enabledTypes, true);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Application\Reports;
use App\Application\Reports\DTO\GenerateReportInput;
use App\Models\Department;
use App\Models\User;
use App\Services\DateRange;
use App\Services\DateRangeService;
use DateTimeImmutable;
final readonly class ReportInputFactory
{
public function __construct(
private DateRangeService $dateRangeService,
) {}
/**
* @param array<string, mixed> $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,
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Application\Reports;
use App\Application\Reports\DTO\GenerateReportResult;
use App\Models\Department;
use App\Models\Report;
use App\Models\User;
use App\Services\DateRange;
use App\Services\ReportService;
final readonly class ReportSavePathService
{
public function __construct(
private ReportFlowDecider $reportFlowDecider,
private ReportInputFactory $reportInputFactory,
private GenerateReportUseCase $generateReportUseCase,
private ReportService $reportService,
) {}
public function usesNewArchitecture(string $reportType = 'daily'): bool
{
return $this->reportFlowDecider->shouldUseNewArchitecture($reportType);
}
/**
* @param array<string, mixed> $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)
);
}
}