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

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

@@ -1,6 +1,7 @@
<?php
use App\Console\Commands\FillReportsFromDate;
use App\Application\Reports\ReportSavePathService;
use App\Models\Department;
use App\Models\User;
use App\Services\AutoReportService;
@@ -279,7 +280,11 @@ it('creates auto-filled report through report service with auto flag and scoped
})
->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()

View File

@@ -0,0 +1,150 @@
<?php
use App\Domain\Reports\Models\ReportSnapshot;
use App\Infrastructure\Reports\Adapters\LegacyReportServiceAdapter;
use App\Infrastructure\Reports\Repositories\EloquentReportRepository;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
foreach ([
'users',
'departments',
'department_metrika_defaults',
'reports',
'metrika_results',
'observation_patients',
'unwanted_events',
] as $table) {
Schema::dropIfExists($table);
}
Schema::create('users', function (Blueprint $table) {
$table->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');
});

View File

@@ -13,7 +13,7 @@
pest()->extend(Tests\TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
->in('Feature', 'Unit');
/*
|--------------------------------------------------------------------------

View File

@@ -0,0 +1,38 @@
<?php
use App\Domain\Reports\Calculators\BedDaysCalculator;
use App\Domain\Reports\Models\StayInterval;
it('calculates total and average bed days', function () {
$calculator = new BedDaysCalculator;
$result = $calculator->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);
});

View File

@@ -0,0 +1,47 @@
<?php
use App\Application\Reports\CompareLegacyAndNewReportUseCase;
use App\Application\Reports\DTO\GenerateReportInput;
use App\Domain\Reports\Contracts\ReportRepository;
use App\Domain\Reports\Models\ReportSnapshot;
use App\Infrastructure\Reports\Adapters\LegacyReportServiceAdapter;
it('returns matched when saved snapshot equals expected snapshot', function () {
$input = new GenerateReportInput(
departmentId: 10,
userId: 5015,
actorUserId: 15,
periodStart: new DateTimeImmutable('2026-04-08 06:00:00'),
periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'),
reportType: 'daily',
rawPayload: [
'departmentId' => 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([]);
});

View File

@@ -0,0 +1,15 @@
<?php
use App\Domain\Reports\Calculators\DepartmentLoadCalculator;
it('calculates department load percentage', function () {
$calculator = new DepartmentLoadCalculator;
expect($calculator->calculate(27, 30))->toBe(90);
});
it('returns zero when beds count is zero', function () {
$calculator = new DepartmentLoadCalculator;
expect($calculator->calculate(27, 0))->toBe(0);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Application\Reports\CompareLegacyAndNewReportUseCase;
use App\Application\Reports\DTO\GenerateReportInput;
use App\Application\Reports\DTO\ReportComparisonResult;
use App\Application\Reports\GenerateReportUseCase;
use App\Domain\Reports\Contracts\AuditLogger;
use App\Domain\Reports\Contracts\PatientSource;
use App\Domain\Reports\Contracts\ReportRepository;
use App\Domain\Reports\Models\PatientCollection;
use App\Domain\Reports\Models\ReportContext;
use App\Domain\Reports\Models\ReportSnapshot;
use App\Domain\Reports\Models\SavedReportResult;
use App\Infrastructure\Reports\Adapters\LegacyReportServiceAdapter;
it('stores generated report and writes comparison audit', function () {
$input = new GenerateReportInput(
departmentId: 10,
userId: 5015,
actorUserId: 15,
periodStart: new DateTimeImmutable('2026-04-08 06:00:00'),
periodEnd: new DateTimeImmutable('2026-04-09 06:00:00'),
reportType: 'daily',
autoFill: true,
);
$patientSource = new class implements PatientSource
{
public function load(ReportContext $context): PatientCollection
{
return new PatientCollection([], [
'payload' => [
'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);
});

View File

@@ -0,0 +1,28 @@
<?php
use App\Domain\Reports\ValueObjects\MetrikaConfig;
it('normalizes metrics from payload keys and numeric ids', function () {
$normalized = MetrikaConfig::normalizeMetrics([
'metrika_item_12' => 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,
]);
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Domain\Reports\Calculators\PreoperativeDaysCalculator;
use App\Domain\Reports\Models\OperationInterval;
it('calculates total and average preoperative days', function () {
$calculator = new PreoperativeDaysCalculator;
$result = $calculator->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);
});

View File

@@ -0,0 +1,58 @@
<?php
use App\Application\Reports\ReportInputFactory;
use App\Models\Department;
use App\Models\User;
use App\Services\DateRange;
use App\Services\DateRangeService;
use Carbon\Carbon;
it('builds manual generate report input from validated payload', function () {
$user = new User;
$user->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);
});

View File

@@ -0,0 +1,148 @@
<?php
use App\Infrastructure\Reports\Services\ReportPatientsReadService;
use App\Infrastructure\Reports\Services\ReportReadContextResolver;
use App\Models\Department;
use App\Models\User;
use App\Services\DateRange;
use App\Services\SnapshotService;
use App\Services\UnifiedPatientService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
it('reads one-day plan patients from snapshots when submitted report exists', 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,
);
$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);
});

View File

@@ -0,0 +1,36 @@
<?php
use App\Domain\Reports\Models\ReportSnapshot;
it('normalizes metrics and exposes comparable payload', function () {
$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: [
'metrika_item_12' => 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);