107 lines
6.4 KiB
PHP
107 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use RuntimeException;
|
|
|
|
/** Exact replay of the 28 accepted links into the transported project identities. */
|
|
final class ApprovedAnchorTerritories
|
|
{
|
|
public const HASH = 'd2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190';
|
|
|
|
/** @return array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} */
|
|
public function plan(): array
|
|
{
|
|
$file = base_path('docs/investmoz/unification/approved-anchor-territories-20261023.json');
|
|
$contents = is_file($file) ? file_get_contents($file) : false;
|
|
if ($contents !== false) {
|
|
$contents = str_replace("\r\n", "\n", $contents);
|
|
}
|
|
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', $contents))) {
|
|
throw new RuntimeException('Reviewed territorial manifest changed.');
|
|
}
|
|
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
|
if (count($manifest['items']) !== 28) {
|
|
throw new RuntimeException('Exact 28-link scope required.');
|
|
}
|
|
$projects = [];
|
|
$links = [];
|
|
foreach ($manifest['items'] as $item) {
|
|
$project = DB::table('projects')->where('id', $item['project_id'])->whereNull('deleted_at')->first();
|
|
$dossier = DB::table('investment_dossiers')->where('id', $item['dossier_id'])->whereNull('deleted_at')->first();
|
|
if (! $project || $project->code !== $item['code'] || ! $dossier || $dossier->project_id !== $project->id
|
|
|| ! hash_equals($item['content_sha256'], hash('sha256', $dossier->content))
|
|
|| ! hash_equals($item['source_sha256'], hash('sha256', $dossier->source))) {
|
|
throw new RuntimeException('Project identity or documentary source changed: '.$item['code']);
|
|
}
|
|
$original = $item['original_relation'];
|
|
$geo = DB::table('geo_locations')->where('id', $original['geo_location_id'])->whereNull('deleted_at')->first();
|
|
foreach (['location_type', 'source_code', 'parent_id', 'data_classification'] as $field) {
|
|
if (! $geo || $item['territory'][$field] !== $geo->$field) {
|
|
throw new RuntimeException('Territory identity changed: '.$original['geo_location_id']);
|
|
}
|
|
}
|
|
$metadata = $projects[$project->id]['after_metadata'] ?? json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR);
|
|
$key = $geo->id.':'.$original['role'];
|
|
$evidence = $item['evidence'];
|
|
// Preserve the original dossier id in evidence and explicitly map the destination.
|
|
$evidence['transported_dossier_id'] = $dossier->id;
|
|
if (isset($metadata['documented_location_sources'][$key]) && $metadata['documented_location_sources'][$key] != $evidence) {
|
|
throw new RuntimeException('Existing territorial evidence differs; no overwrite: '.$item['code']);
|
|
}
|
|
$projects[$project->id] ??= ['id' => $project->id, 'code' => $project->code, 'before_version' => (int) $project->version_no, 'before_metadata' => json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR)];
|
|
$metadata['documented_location_sources'][$key] = $evidence;
|
|
$projects[$project->id]['after_metadata'] = $metadata;
|
|
$existing = DB::table('project_locations')->where('project_id', $project->id)->where('geo_location_id', $geo->id)->where('role', $original['role'])->first();
|
|
if ($existing && ($existing->deleted_at || $existing->data_classification !== 'public')) {
|
|
throw new RuntimeException('Archived or restricted territorial relation must not be revived.');
|
|
}
|
|
$identity = DB::table('project_locations')->where('id', $original['id'])->first();
|
|
if ($identity && (! $existing || $identity->id !== $existing->id)) {
|
|
throw new RuntimeException('Historical relation identity is already occupied.');
|
|
}
|
|
$links[] = ['code' => $item['code'], 'already_present' => (bool) $existing, 'row' => [...$original, 'project_id' => $project->id]];
|
|
}
|
|
|
|
return ['manifest_sha256' => self::HASH, 'projects' => array_values($projects), 'links' => $links];
|
|
}
|
|
|
|
/** Caller supplies an authenticated, backed-up transaction; no source publication changes.
|
|
* @param array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} $plan
|
|
* @return array<string,int>
|
|
*/
|
|
public function apply(array $plan): array
|
|
{
|
|
if (DB::transactionLevel() < 1 || $this->plan() !== $plan) {
|
|
throw new RuntimeException('Reviewed transaction and unchanged preview required.');
|
|
}
|
|
$inserted = 0;
|
|
$updated = 0;
|
|
foreach ($plan['projects'] as $project) {
|
|
if ($project['before_metadata'] == $project['after_metadata']) {
|
|
continue;
|
|
}
|
|
$count = DB::table('projects')->where('id', $project['id'])->where('version_no', $project['before_version'])->update([
|
|
'metadata' => json_encode($project['after_metadata'], JSON_THROW_ON_ERROR), 'version_no' => $project['before_version'] + 1, 'updated_at' => now(),
|
|
]);
|
|
if ($count !== 1) {
|
|
throw new RuntimeException('Concurrent project change.');
|
|
}
|
|
DB::table('audit_events')->insert(['source_system' => 'approved-anchor-territories-recovery-v1', 'external_id' => self::HASH.':'.$project['id'], 'entity_type' => 'projects', 'entity_id' => $project['id'], 'action' => 'accepted_territories_restored',
|
|
'data_classification' => 'public', 'before_data' => json_encode(['version_no' => $project['before_version'], 'metadata' => $project['before_metadata']], JSON_THROW_ON_ERROR),
|
|
'after_data' => json_encode(['version_no' => $project['before_version'] + 1, 'manifest_sha256' => self::HASH, 'metadata' => $project['after_metadata'], 'new_approval' => false], JSON_THROW_ON_ERROR)]);
|
|
$updated++;
|
|
}
|
|
foreach ($plan['links'] as $link) {
|
|
if ($link['already_present']) {
|
|
continue;
|
|
}
|
|
DB::table('project_locations')->insert($link['row']);
|
|
$inserted++;
|
|
}
|
|
|
|
return ['relations_restored' => $inserted, 'projects_updated' => $updated, 'new_dossiers' => 0, 'publication_changes' => 0];
|
|
}
|
|
}
|