98 lines
5.9 KiB
PHP
98 lines
5.9 KiB
PHP
<?php
|
|
|
|
// Read-only snapshot of the guarded QA restore; no active connection or catalogue writes.
|
|
use App\Models\User;
|
|
use App\Services\TerritoryGeometryImport;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
$root = dirname(__DIR__);
|
|
require $root.'/vendor/autoload.php';
|
|
require __DIR__.'/portal-qa-environment.php';
|
|
$target = portalQaEnvironment();
|
|
$app = require $root.'/bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
portalQaAssertTarget($target);
|
|
if (($argv[1] ?? '') === 'preview') {
|
|
config(['geometry-import.enabled' => true, 'portal.managers' => ['geo-manager-qa@example.invalid']]);
|
|
$actor = User::where('email', 'geo-manager-qa@example.invalid')->firstOrFail();
|
|
$plans = DB::transaction(function () use ($actor) {
|
|
DB::statement('SET TRANSACTION READ ONLY');
|
|
|
|
return array_map(fn ($level) => app(TerritoryGeometryImport::class)->preview($actor, $level), [1, 2, 3]);
|
|
});
|
|
foreach ($plans as $plan) {
|
|
echo json_encode(['level' => $plan['level'], 'expected' => $plan['expected'], 'resolved' => $plan['resolved'], 'pending' => $plan['pending'], 'scan_ready' => $plan['scan_ready'],
|
|
'errors' => array_values(array_filter($plan['rows'], fn ($row) => str_contains($row['status'], 'conflict') || $row['status'] === 'invalid_geometry'))], JSON_THROW_ON_ERROR).PHP_EOL;
|
|
}
|
|
exit;
|
|
}
|
|
$snapshot = DB::transaction(function () {
|
|
DB::statement('SET TRANSACTION READ ONLY');
|
|
|
|
return [
|
|
'locations' => DB::table('geo_locations')->orderBy('location_type')->orderBy('name')->get(),
|
|
'geometries' => DB::table('geo_geometries')->selectRaw("id,external_id,geo_location_id,source_system,properties,data_classification,version_no,deleted_at,encode(ST_AsEWKB(geom),'hex') as geometry_bytes")->orderBy('id')->get(),
|
|
];
|
|
});
|
|
$snapshot['target'] = $target;
|
|
$snapshot['captured_at'] = gmdate('c');
|
|
$snapshot['catalogue_hash'] = hash('sha256', json_encode($snapshot['locations'], JSON_THROW_ON_ERROR));
|
|
$snapshot['geometries_hash'] = hash('sha256', json_encode($snapshot['geometries'], JSON_THROW_ON_ERROR));
|
|
$snapshot['active_writes'] = 0;
|
|
if (($argv[1] ?? '') === 'verify') {
|
|
$baseline = json_decode(file_get_contents(storage_path('app/private/investmoz-v2/portal-audit/geometry-reconciliation-baseline.json')), true, 512, JSON_THROW_ON_ERROR);
|
|
$oldLocations = json_encode($baseline['locations'], JSON_THROW_ON_ERROR);
|
|
$locationsPreserved = hash_equals(hash('sha256', $oldLocations), $snapshot['catalogue_hash']);
|
|
$current = [];
|
|
foreach ($snapshot['geometries'] as $row) {
|
|
$current[$row->id] = (array) $row;
|
|
}
|
|
foreach ($baseline['geometries'] as $row) {
|
|
if (! isset($current[$row['id']]) || json_encode($row, JSON_THROW_ON_ERROR) !== json_encode($current[$row['id']], JSON_THROW_ON_ERROR)) {
|
|
throw new RuntimeException('An original QA geometry changed; preservation proof refused.');
|
|
}
|
|
unset($current[$row['id']]);
|
|
}
|
|
if (! $locationsPreserved) {
|
|
throw new RuntimeException('The existing QA catalogue changed; preservation proof refused.');
|
|
}
|
|
$locationTypes = [];
|
|
foreach ($snapshot['locations'] as $row) {
|
|
$locationTypes[$row->id] = $row->location_type;
|
|
}
|
|
$new = [];
|
|
$counts = [];
|
|
foreach ($snapshot['geometries'] as $row) {
|
|
if (! $row->deleted_at) {
|
|
$type = $locationTypes[$row->geo_location_id];
|
|
$counts[$type] = ($counts[$type] ?? 0) + 1;
|
|
}
|
|
if (isset($current[$row->id])) {
|
|
$properties = json_decode($row->properties, true, 512, JSON_THROW_ON_ERROR);
|
|
if ($row->data_classification !== 'internal' || $row->deleted_at || ($properties['investment_site'] ?? null) !== false) {
|
|
throw new RuntimeException('Unexpected disclosure/lifecycle/purpose of new QA boundary.');
|
|
}
|
|
$new[] = ['id' => $row->id, 'location_id' => $row->geo_location_id, 'type' => $locationTypes[$row->geo_location_id], 'external_id' => $row->external_id, 'properties' => $properties];
|
|
}
|
|
}
|
|
$imports = DB::transaction(function () {
|
|
DB::statement('SET TRANSACTION READ ONLY');
|
|
|
|
return DB::table('audit_events')->where('source_system', 'geometry-import')->where('action', 'geometry_import_applied')->orderBy('occurred_at')->get(['entity_id', 'actor_user_id', 'occurred_at', 'after_data']);
|
|
});
|
|
$proof = ['captured_at' => gmdate('c'), 'target' => $target, 'catalogue_count' => count($snapshot['locations']), 'catalogue_preserved' => true,
|
|
'catalogue_hash' => $snapshot['catalogue_hash'], 'original_geometries_preserved' => count($baseline['geometries']), 'new_geometries' => $new,
|
|
'geometries_by_level' => $counts, 'total_active' => array_sum($counts), 'authenticated_import_events' => $imports,
|
|
'published' => false, 'read_only_verification' => true, 'interface_observed' => false, 'active_writes' => 0];
|
|
file_put_contents(storage_path('app/private/investmoz-v2/portal-audit/geometry-crosswalk-preservation-qa.json'), json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
echo json_encode(['target' => $target, 'catalogue_preserved' => true, 'original_geometries_preserved' => count($baseline['geometries']), 'new_geometries' => count($new), 'counts' => $counts, 'total' => array_sum($counts), 'published' => false, 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
|
exit;
|
|
}
|
|
$file = storage_path('app/private/investmoz-v2/portal-audit/geometry-reconciliation-baseline.json');
|
|
if (is_file($file)) {
|
|
$file = dirname($file).'/geometry-reconciliation-snapshot-'.gmdate('Ymd_His').'.json';
|
|
}
|
|
file_put_contents($file, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
echo json_encode(['target' => $target, 'locations' => count($snapshot['locations']), 'geometries' => count($snapshot['geometries']), 'catalogue_hash' => $snapshot['catalogue_hash'], 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|