diff --git a/.github/workflows/setup-logrotate.yml b/.github/workflows/setup-logrotate.yml index 4fec4b0c..554aa5dd 100644 --- a/.github/workflows/setup-logrotate.yml +++ b/.github/workflows/setup-logrotate.yml @@ -100,10 +100,50 @@ jobs: sudo logrotate --force /etc/logrotate.d/laravel-liderra 2>&1 | tail -10 echo - echo "=== 5. AFTER: log directory state ===" + echo "=== 5. PostgreSQL log rotation config ===" + # Default Ubuntu postgresql-common rotates daily without size cap. + # We override with size 100M / rotate 7 / postrotate SIGHUP (PG reopens log). + # Higher alpha order than postgresql-common → processed later → wins on same files. + sudo tee /etc/logrotate.d/postgresql-liderra > /dev/null </dev/null || true + fi + endscript + } + EOF + echo "Wrote /etc/logrotate.d/postgresql-liderra:" + sudo cat /etc/logrotate.d/postgresql-liderra + sudo chmod 0644 /etc/logrotate.d/postgresql-liderra + + echo + echo "=== 6. Verify PG logrotate syntax ===" + sudo logrotate --debug /etc/logrotate.d/postgresql-liderra 2>&1 | head -20 + + echo + echo "=== 7. Force PG log rotation now (clean state) ===" + sudo logrotate --force /etc/logrotate.d/postgresql-liderra 2>&1 | tail -10 + + echo + echo "=== 8. AFTER: PG log directory state ===" + sudo ls -lah /var/log/postgresql/ 2>&1 | head -10 + + echo + echo "=== 9. AFTER: Laravel log directory state ===" sudo ls -lah "$LARAVEL_LOG_DIR/" 2>&1 | head -20 echo - echo "=== 6. Disk free ===" + echo "=== 10. Disk free ===" df -h / 2>&1 | head -3 echo diff --git a/app/app/Models/PdSubjectRequest.php b/app/app/Models/PdSubjectRequest.php index b17b2ac8..6094c1bd 100644 --- a/app/app/Models/PdSubjectRequest.php +++ b/app/app/Models/PdSubjectRequest.php @@ -29,6 +29,8 @@ use Illuminate\Support\Facades\DB; * @property string $deadline_at * @property string|null $completed_at * @property bool $processing_restricted + * + * @mixin IdeHelperPdSubjectRequest */ class PdSubjectRequest extends Model { diff --git a/app/app/Models/SupplierLeadDelivery.php b/app/app/Models/SupplierLeadDelivery.php index 5011de6c..0eb09923 100644 --- a/app/app/Models/SupplierLeadDelivery.php +++ b/app/app/Models/SupplierLeadDelivery.php @@ -8,12 +8,15 @@ use Illuminate\Database\Eloquent\Model; /** * Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента. + * * Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом. * * @property int $supplier_lead_id * @property int $tenant_id * @property int|null $deal_id * @property string $created_at + * + * @mixin IdeHelperSupplierLeadDelivery */ class SupplierLeadDelivery extends Model { diff --git a/app/app/Models/SupplierManualSyncQueue.php b/app/app/Models/SupplierManualSyncQueue.php index ab1bafbe..d06606e6 100644 --- a/app/app/Models/SupplierManualSyncQueue.php +++ b/app/app/Models/SupplierManualSyncQueue.php @@ -25,6 +25,8 @@ use Illuminate\Support\Carbon; * @property int|null $resolved_by_user_id * @property Carbon|null $created_at * @property Carbon|null $resolved_at + * + * @mixin IdeHelperSupplierManualSyncQueue */ class SupplierManualSyncQueue extends Model { diff --git a/app/app/Services/Supplier/SupplierProjectGrouping.php b/app/app/Services/Supplier/SupplierProjectGrouping.php index dd3630a2..c285cbdd 100644 --- a/app/app/Services/Supplier/SupplierProjectGrouping.php +++ b/app/app/Services/Supplier/SupplierProjectGrouping.php @@ -72,7 +72,6 @@ final class SupplierProjectGrouping public static function subjectsOf(Project $project): array { $regions = array_values((array) $project->regions); - // @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty if (count($regions) === 0) { return [null]; } diff --git a/app/deptrac.yaml b/app/deptrac.yaml index dd67cd52..ffca4b48 100644 --- a/app/deptrac.yaml +++ b/app/deptrac.yaml @@ -41,6 +41,9 @@ deptrac: Request: [Rule, Model] Resource: [Model] Rule: [Model] - Mail: [Model] + # Mail может зависеть от Service value objects (PreflightResult и аналоги) — + # это legit dependency: template needs data DTO от Service для рендера. + # Decision: ADR-005 amend 2026-05-29 (incident-followup cleanup). + Mail: [Model, Service] Model: [] Provider: [Controller, Service, Job, Console, Repository, Model, Mail, Middleware, Request, Resource, Rule, Exception] diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 412e395d..33c179c0 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -51,7 +51,7 @@ parameters: - message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#' identifier: nullsafe.neverNull - count: 5 + count: 6 path: app/Http/Controllers/Api/DealController.php - @@ -84,6 +84,24 @@ parameters: count: 1 path: app/Http/Middleware/SetTenantContext.php + - + message: '#^Access to an undefined property App\\Http\\Resources\\ProjectResource\:\:\$applies_from\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/ProjectResource.php + + - + message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' + identifier: arrayValues.list + count: 1 + path: app/Jobs/Supplier/SyncSupplierProjectsJob.php + + - + message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of App\\Models\\Project, ''snap\.snapshot_date'' given\.$#' + identifier: argument.type + count: 1 + path: app/Jobs/Supplier/SyncSupplierProjectsJob.php + - message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' identifier: nullsafe.neverNull @@ -102,6 +120,12 @@ parameters: count: 1 path: app/Services/NotificationService.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#' + identifier: property.notFound + count: 1 + path: app/Services/Project/ProjectService.php + - message: '#^Match expression does not handle remaining value\: string$#' identifier: match.unhandled @@ -120,6 +144,90 @@ parameters: count: 1 path: app/Services/Supplier/Channel/AjaxProjectChannel.php + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + + - + message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#' + identifier: class.notFound + count: 1 + path: config/insights.php + - message: '#^Return type \(array\\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -156,6 +264,12 @@ parameters: count: 1 path: database/factories/UserFactory.php + - + message: '#^Offset ''SnapshotProjectRout…'' on null in isset\(\) does not exist\.$#' + identifier: isset.offset + count: 1 + path: routes/console.php + - message: '#^Offset ''projects\:reset…'' on null in isset\(\) does not exist\.$#' identifier: isset.offset @@ -444,6 +558,18 @@ parameters: count: 3 path: tests/Feature/ApiKeyControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Audit/AuditChainRaceConditionTest.php + + - + message: '#^Using nullsafe property access "\?\-\>cnt" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: tests/Feature/Audit/AuditRebuildChainTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound @@ -720,6 +846,36 @@ parameters: count: 6 path: tests/Feature/Auth/UpdateProfileTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/Billing/BalanceStatusTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Billing/BalanceStatusTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/BalanceStatusTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 6 + path: tests/Feature/Billing/BalanceStatusTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/BalanceStatusTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -750,10 +906,16 @@ parameters: count: 1 path: tests/Feature/Billing/BillingOverviewControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Billing/BillingPreflightInitialSweepTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#' identifier: property.notFound - count: 8 + count: 9 path: tests/Feature/Billing/LedgerServiceTest.php - @@ -768,6 +930,12 @@ parameters: count: 6 path: tests/Feature/Billing/PricingTierRepositoryTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Billing/ProjectPreflightTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -876,6 +1044,30 @@ parameters: count: 1 path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Console/SnapshotBackfillCommandTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 2 + path: tests/Feature/Console/SnapshotBackfillCommandTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Console/SnapshotRebuildCommandTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 2 + path: tests/Feature/Console/SnapshotRebuildCommandTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound @@ -1296,6 +1488,12 @@ parameters: count: 5 path: tests/Feature/EndpointAuthHardeningTest.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Http/Resources/ProjectResourceAppliesFromTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound @@ -1308,6 +1506,18 @@ parameters: count: 2 path: tests/Feature/Http/Webhook/SupplierWebhookTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:call\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#' identifier: property.notFound @@ -1422,6 +1632,12 @@ parameters: count: 8 path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Incidents/SingleLeadStormTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound @@ -1434,12 +1650,48 @@ parameters: count: 1 path: tests/Feature/Integration/SupplierLeadFlowTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 2 + path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' identifier: method.notFound count: 1 path: tests/Feature/Jobs/RouteSupplierLeadJobTest.php + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 1 + path: tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 4 + path: tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 1 + path: tests/Feature/LeadRouter/FrozenFilterTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 4 + path: tests/Feature/LeadRouter/SnapshotRoutingTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound @@ -2016,6 +2268,24 @@ parameters: count: 3 path: tests/Feature/Security/WebhookUrlChangeAuditTest.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 4 + path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php + + - + message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#' + identifier: arguments.count + count: 5 + path: tests/Feature/Services/Project/SupplierSnapshotGuardAppliesFromTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' identifier: property.notFound @@ -2064,12 +2334,48 @@ parameters: count: 1 path: tests/Feature/Supplier/CsvReconcileJobTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Supplier/CsvWebhookRaceTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/Supplier/CsvWebhookRaceTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 8 + path: tests/Feature/Supplier/CsvWebhookRaceTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Supplier/CsvWebhookRaceTest.php + - message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' identifier: method.notFound count: 1 path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Supplier/DirectPlatformTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Supplier/DirectPlatformTest.php + - message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#' identifier: method.notFound @@ -2148,12 +2454,30 @@ parameters: count: 1 path: tests/Feature/Supplier/SupplierProjectImporterTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Supplier/SupplierRekeyOrphansCommandTest.php + - message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#' identifier: method.notFound count: 1 path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sharedProject\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php + + - + message: '#^Using nullsafe property access "\?\-\>error" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 2 + path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound @@ -2274,6 +2598,42 @@ parameters: count: 6 path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 3 + path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php + + - + message: '#^Parameter \#2 \$snapshotGuard of class App\\Services\\Project\\ProjectService constructor expects App\\Services\\Project\\SupplierSnapshotGuard, Mockery\\MockInterface given\.$#' + identifier: argument.type + count: 3 + path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php + + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:with\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php + + - + message: '#^Property App\\Models\\IdeHelperProject\:\:\$paused_at \(Illuminate\\Support\\Carbon\|null\) does not accept Carbon\\CarbonImmutable\.$#' + identifier: assign.propertyType + count: 2 + path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php + - message: '#^Call to an undefined method App\\Services\\Supplier\\ProcessFactory\:\:shouldReceive\(\)\.$#' identifier: method.notFound diff --git a/app/tests/Feature/Supplier/SupplierConnectionTest.php b/app/tests/Feature/Supplier/SupplierConnectionTest.php index 703e1546..420e09a4 100644 --- a/app/tests/Feature/Supplier/SupplierConnectionTest.php +++ b/app/tests/Feature/Supplier/SupplierConnectionTest.php @@ -104,7 +104,6 @@ test("LeadRouter видит проекты всех tenant'ов под pgsql_sup 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, 'platform' => $supplier->platform, - // @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag 'subject_code' => $supplier->subject_code, ]); createRoutingSnapshotFromProject($project, null, 'site', 'plan3-task3-warn2.example.com', 10); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index b098ef3a..1812e7f2 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -2,7 +2,9 @@ use App\Models\Project; use App\Models\SupplierProject; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -69,7 +71,6 @@ function linkProjectToSupplier(Project $project, SupplierProject $supplier): voi 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, 'platform' => $supplier->platform, - // @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag 'subject_code' => $supplier->subject_code, ]); } @@ -106,7 +107,7 @@ function insertSnapshotForTomorrow( ?int $deliveryDaysMask = null, string $regions = '{}', ): void { - $tomorrow = \Carbon\Carbon::tomorrow('Europe/Moscow')->toDateString(); + $tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString(); DB::table('project_routing_snapshots')->insert([ 'snapshot_date' => $tomorrow, 'project_id' => $project->id, @@ -120,7 +121,7 @@ function insertSnapshotForTomorrow( 'sms_keyword' => null, 'expected_volume' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10), 'delivered_count' => 0, - 'created_at' => \Illuminate\Support\Facades\Date::now(), + 'created_at' => Date::now(), ]); } @@ -132,7 +133,7 @@ function createRoutingSnapshotFromProject( ?int $dailyLimit = null, ): void { DB::table('project_routing_snapshots')->insert([ - 'snapshot_date' => $date ?? \Carbon\Carbon::today('Europe/Moscow')->toDateString(), + 'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(), 'project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target), @@ -144,6 +145,6 @@ function createRoutingSnapshotFromProject( 'sms_keyword' => null, 'expected_volume' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target), 'delivered_count' => 0, - 'created_at' => \Illuminate\Support\Facades\Date::now(), + 'created_at' => Date::now(), ]); } diff --git a/cspell-words.txt b/cspell-words.txt index b751a2d8..e79e96ea 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1940,3 +1940,8 @@ mvn popen брэйншторм стопаем + +# 2026-05-29 incident-followup cleanup +notifempty +missingok +верифицируется diff --git a/docs/adr/ADR-005-architecture-fitness-deptrac.md b/docs/adr/ADR-005-architecture-fitness-deptrac.md index 64204fc4..0304c1e0 100644 --- a/docs/adr/ADR-005-architecture-fitness-deptrac.md +++ b/docs/adr/ADR-005-architecture-fitness-deptrac.md @@ -42,3 +42,29 @@ narrow for dependency-direction rules. The layer rules live in `app/deptrac.yaml`, enforced by lefthook pre-commit job 10 (`deptrac analyse`) — not by an `adr-judge` regex. This ADR therefore carries no `adr-judge`-parsed Enforcement clause. + +## Amendments + +### 2026-05-29 — Mail ⟶ Service value objects allowed + +After Stage 4 slepok routing protection rollout, billing introduced +`PreflightResult` (`app/Services/Billing/PreflightResult.php`) — a value +object representing a pre-flight check result, used by Mail templates +(`BalanceFrozenReminderMail`, `BalanceUnfrozenMail`, `BalanceFrozenMail`, +`BalanceFrozenFinalMail`) для рендера email с runtime данными. + +Original ruleset: `Mail: [Model]` — blocked Mail ⟶ Service deps. +4 pre-existing violations accumulated, unnoticed until incident 2026-05-29 +(`docs/incidents/2026-05-29-disk-full-pg-recovery.md`) forced first PHP commit. + +**Decision:** Mail layer **может** depend на Service value objects (DTOs, +readonly result classes). Это template-rendering legitimate need: Mail +получает data DTO от Service и рендерит — no business logic, just data +projection. + +Updated ruleset: `Mail: [Model, Service]`. Result: 0 violations. + +**NB:** этот allowance не открывает Mail к active Service calls (e.g. invoking +`LedgerService::charge()` из template). Convention: Mail может только **read** +readonly Service DTOs, не вызывать mutating Service methods. Enforcement +этого convention pending — currently deptrac granularity layer-level only. diff --git a/docs/incidents/2026-05-29-disk-full-pg-recovery.md b/docs/incidents/2026-05-29-disk-full-pg-recovery.md index c6079967..8f57a53d 100644 --- a/docs/incidents/2026-05-29-disk-full-pg-recovery.md +++ b/docs/incidents/2026-05-29-disk-full-pg-recovery.md @@ -91,24 +91,51 @@ ## 4. Что НЕ сделано (deferred) -### 4.1. F1 chain rebuild для 2 битых партиций +### 4.1. F1 chain rebuild — 6 residual mismatches в activity_log (algorithm divergence) -**Что:** `activity_log_y2026_m05` с id=599 + `balance_transactions_y2026_m05` с id=462 имеют split hash chain (несколько записей с одним `prev_hash` из-за race до advisory-lock fix). +**Что сделано (29.05.2026 incident-followup):** новый workflow +`.github/workflows/f1-rebuild-via-superuser.yml` через `sudo -u postgres psql` +выполняет plpgsql DO-блок с sequential hash recomputation: -**Почему deferred:** команда `audit:rebuild-chain` использует `SET session_replication_role = 'replica'` для временного отключения триггера `audit_chain_hash`. Эта операция требует postgres superuser. Connection `pgsql_supplier` (роль `crm_supplier_worker` с BYPASSRLS) этих прав не имеет. +- `balance_transactions_y2026_m05` (213 rows, ids 462..674): rebuilt → **0 mismatches**, + verify-chains intact ✅ +- `activity_log_y2026_m05` (186 rows, ids 599..784): rebuilt → **6 mismatches остаются** ⚠️ -**Когда нужно сделать:** для целостности 152-ФЗ аудит-журнала. **Не блокирует бизнес** — F1 advisory-lock уже применён, новые записи защищены от race. +**Корневая причина 6 residual mismatches — algorithm divergence в самом проекте:** -**Как сделать:** новый workflow `f1-rebuild-via-superuser.yml` который через `sudo -u postgres psql` выполнит plpgsql DO-блок с sequential hash recomputation. Алгоритм: +| Алгоритм | Файл | Chain semantics | +|---|---|---| +| **Trigger** (writes hashes) | `db/schema.sql` `audit_chain_hash()` | `SELECT log_hash FROM ORDER BY id DESC LIMIT 1` — **global** chain (no tenant filter) | +| **Rebuild canonical** (artisan) | `app/Console/Commands/AuditRebuildChain.php` | Same as trigger — global ORDER BY id | +| **Verify** | `app/Console/Commands/VerifyAuditChains.php` `TABLE_CONFIG['activity_log']` | `'partition' => 'PARTITION BY tenant_id'` — **per-tenant** chain | -1. `SELECT log_hash FROM WHERE id = ` → prev_hash -2. For each row WHERE id >= ORDER BY id: - - new_hash = digest(prev_hash || row::text::bytea, 'sha256') - - UPDATE row SET log_hash = new_hash - - prev_hash := new_hash -3. COMMIT +Trigger и rebuild создают global chain (per partition table). Verify ожидает +per-tenant chain. Когда `activity_log_y2026_m05` содержит multiple tenants — +trigger-produced global chain не выглядит intact для verify. -Требует написания и тестирования. Запланировать отдельной сессией. +`balance_transactions` верифицируется без `partition`-config (global) → matches trigger/rebuild → 0 mismatches. + +`activity_log` верифицируется с `partition: 'PARTITION BY tenant_id'` → +divergent от global trigger → 6 mismatches (это **multi-tenant rows where chain order +differs by tenant_id grouping**). + +**Не блокирует бизнес:** F1 advisory-lock защищает от *новых* race-mismatches. +Существующие 6 rows — historical data integrity gap в 152-ФЗ журнале, не операционный. + +**Что нужно решить (отдельная сессия / ADR):** + +Один из двух путей: + +1. **Align verify с trigger:** изменить `TABLE_CONFIG['activity_log']['partition']` + с `'PARTITION BY tenant_id'` на `''` (global). Pros: minimum code change. + Cons: ослабляет 152-ФЗ guarantee — global chain через всех tenants + позволяет cross-tenant tampering detection слабее. +2. **Align trigger с verify:** изменить trigger `audit_chain_hash()` чтобы + читал prev_hash с `WHERE tenant_id = NEW.tenant_id`. Pros: stronger + per-tenant 152-ФЗ. Cons: миграция всех existing audit rows + rebuild + tool needs per-tenant variant. + +Decision требует ADR + design session. **Pending.** ### 4.2. PG log file 498 МБ