425 lines
25 KiB
PHP
425 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentZone;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
/** Editorial preparation in the existing master. No operational/land entitlement is inferred. */
|
|
class InvestmentZoneOperationalProfile
|
|
{
|
|
public const MIGRATION = '2026_10_16_130000_add_investment_zone_editorial_draft';
|
|
|
|
public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id',
|
|
'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence',
|
|
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory', 'primary_territory_id', 'territory_version'];
|
|
|
|
public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id'];
|
|
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasColumn('investment_zones', 'editorial_draft');
|
|
}
|
|
|
|
public function editorialEnabled(): bool
|
|
{
|
|
return (bool) config('editorial_workflow.zone_review') && $this->installed();
|
|
}
|
|
|
|
public function ensureWriteMode(InvestmentZone $zone): void
|
|
{
|
|
if (config('editorial_workflow.zone_review')) {
|
|
abort_unless($this->editorialEnabled(), 503, 'A preparação privada das zonas ainda não está instalada.');
|
|
} elseif ($zone->exists) {
|
|
abort_if(DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_id', $zone->id)->where('after_data->editorial_workflow', true)->exists(), 503, 'Reactive a revisão das zonas para preservar o histórico.');
|
|
}
|
|
}
|
|
|
|
public function reviewAccess(User $actor): bool
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
|
|
return $actor->hasVerifiedEmail() && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! $access->has($actor, 'auditor')
|
|
&& ($actor->can('manage-portal') || $access->superuser($actor) || $access->legacyWriteAllowed($actor));
|
|
}
|
|
|
|
private function reviewActor(User $actor): void
|
|
{
|
|
abort_unless($this->reviewAccess($actor) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Âmbito administrativo e MFA válido necessários.');
|
|
}
|
|
|
|
public function authorizeWrite(int $actor): void
|
|
{
|
|
$this->reviewActor(User::findOrFail($actor));
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $draft */
|
|
private function reviewHash(InvestmentZone $zone, array $draft): string
|
|
{
|
|
$context = ['public_fields' => Arr::only($zone->getRawOriginal(), [...self::FIELDS, 'primary_territory_id', 'territory_version', 'operational_details']), 'locations' => [], 'documents' => []];
|
|
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations', 'primary_territory_id' => 'geo_locations'] as $field => $table) {
|
|
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
|
|
if ($id) {
|
|
$row = DB::table($table)->where('id', $id)->first();
|
|
abort_unless($row !== null, 422, 'Fonte ou contexto da zona indisponível.');
|
|
$context[$field] = Arr::except(get_object_vars($row), ['created_at', 'updated_at']);
|
|
}
|
|
}
|
|
$context['locations'] = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->get()->map(fn ($row) => Arr::except(get_object_vars($row), ['created_at', 'updated_at']))->all();
|
|
$geography = [];
|
|
$locations = array_filter([$draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id'), ...array_column($context['locations'], 'geo_location_id')]);
|
|
foreach (array_unique($locations) as $location) {
|
|
$seen = [];
|
|
while ($location) {
|
|
abort_if(in_array($location, $seen, true) || count($seen) >= 5, 422, 'Hierarquia territorial incoerente.');
|
|
$seen[] = $location;
|
|
$node = DB::table('geo_locations')->where('id', $location)->whereNull('deleted_at')->first();
|
|
abort_unless($node !== null, 422, 'Contexto territorial indisponível.');
|
|
$geography[$node->id] = Arr::only(get_object_vars($node), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'source_code']);
|
|
$location = $node->parent_id;
|
|
}
|
|
}
|
|
ksort($geography);
|
|
$context['geography'] = $geography;
|
|
foreach (DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->get() as $attachment) {
|
|
$document = DB::table('documents')->where('id', $attachment->document_id)->first();
|
|
abort_unless($document !== null, 422, 'Anexo indisponível.');
|
|
if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) {
|
|
app(VerifiedDocumentBinary::class)->respond($document);
|
|
}
|
|
$context['documents'][] = ['attachment' => Arr::except(get_object_vars($attachment), ['created_at', 'updated_at']), 'document' => Arr::only(get_object_vars($document), ['id', 'sha256', 'version_no', 'status', 'deleted_at', 'rights_status', 'malware_scan_status', 'malware_scanned_sha256'])];
|
|
}
|
|
|
|
return hash('sha256', json_encode(Arr::sortRecursive(['fields' => $draft['fields'], 'context' => $context, 'contributors' => $draft['editorial']['contributors'] ?? []]), JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $draft */
|
|
private function freshReview(InvestmentZone $zone, array $draft): bool
|
|
{
|
|
try {
|
|
return (int) $draft['base_public_version'] === (int) $zone->version_no && hash_equals($draft['editorial']['hash'], $this->reviewHash($zone, $draft));
|
|
} catch (HttpException $exception) {
|
|
if (in_array($exception->getStatusCode(), [404, 409, 422], true)) {
|
|
return false;
|
|
}
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $workflow */
|
|
private function independent(array $workflow): bool
|
|
{
|
|
return ($workflow['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true);
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $draft
|
|
* @param array<array-key,mixed>|null $previous
|
|
* @return array<array-key,mixed>
|
|
*/
|
|
private function newReview(InvestmentZone $zone, array $draft, int $actor, ?array $previous): array
|
|
{
|
|
$draft['editorial'] = ['state' => 'draft', 'content_version' => $draft['working_version'], 'contributors' => array_values(array_unique([...($previous['editorial']['contributors'] ?? []), $actor])),
|
|
'independent_required' => ($previous['editorial']['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true), 'approval' => null];
|
|
$draft['editorial']['hash'] = $this->reviewHash($zone, $draft);
|
|
|
|
return $draft;
|
|
}
|
|
|
|
/** @return array<array-key,mixed> */
|
|
public function review(string $id, User $actor, int $expected, string $action, string $reason): array
|
|
{
|
|
$this->reviewActor($actor);
|
|
validator(['reason' => $reason], ['reason' => 'required|string|min:10|max:3000'])->validate();
|
|
abort_unless($this->editorialEnabled(), 503);
|
|
|
|
return DB::transaction(function () use ($id, $actor, $expected, $action, $reason) {
|
|
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
$draft = $this->draft($zone);
|
|
abort_unless($expected === $this->workingVersion($zone), 409, 'A preparação mudou. Conserve os campos e confira a versão actual.');
|
|
if (! $draft || ! isset($draft['editorial'])) {
|
|
abort_unless($action === 'submit', 409, 'Prepare a revisão antes de decidir.');
|
|
$zone = $this->prepare($zone, ['version_no' => $expected], $actor->id);
|
|
$draft = $this->draft($zone);
|
|
}
|
|
abort_unless($draft !== null && isset($draft['editorial']), 409);
|
|
$workflow = $draft['editorial'];
|
|
$allowed = ['submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'approve' => ['in_review'], 'return' => ['in_review'], 'reject' => ['in_review'], 'archive' => ['draft', 'submitted', 'in_review', 'approved', 'returned', 'rejected']];
|
|
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409);
|
|
if (in_array($action, ['submit', 'approve'], true)) {
|
|
abort_unless($this->freshReview($zone, $draft), 409, 'Conteúdo, fonte ou anexos alterados. Guarde nova edição e volte a conferir.');
|
|
}
|
|
if ($action === 'approve') {
|
|
abort_if($this->independent($workflow) && in_array($actor->id, $workflow['contributors'], true), 403, 'Esta edição exige aprovação independente.');
|
|
$workflow['approval'] = ['actor_id' => $actor->id, 'content_version' => $workflow['content_version'], 'hash' => $workflow['hash'], 'reason' => $reason, 'at' => now()->toISOString()];
|
|
}
|
|
$workflow['state'] = ['submit' => 'submitted', 'start_review' => 'in_review', 'approve' => 'approved', 'return' => 'returned', 'reject' => 'rejected', 'archive' => 'archived'][$action];
|
|
$draft['editorial'] = $workflow;
|
|
$draft['working_version'] = $this->nextVersion($zone);
|
|
DB::table('investment_zones')->where('id', $id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone, $draft['working_version'], $actor->id, 'zone_editorial_'.$action, ['draft' => $draft, 'reason' => $reason]);
|
|
|
|
return $this->management($zone->fresh());
|
|
});
|
|
}
|
|
|
|
public function suspensionPreservesReview(InvestmentZone $zone, int $actor): void
|
|
{
|
|
$draft = $this->draft($zone);
|
|
if (! $this->editorialEnabled() || ! $draft) {
|
|
return;
|
|
}
|
|
$draft['base_public_version'] = (int) $zone->version_no;
|
|
$draft['working_version'] = $this->nextVersion($zone);
|
|
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone, $draft['working_version'], $actor, 'zone_editorial_suspension_rebased', ['draft' => $draft]);
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>|null
|
|
*/
|
|
public function draft(InvestmentZone $zone): ?array
|
|
{
|
|
$raw = $zone->getRawOriginal('editorial_draft');
|
|
if (! $raw) {
|
|
return null;
|
|
}
|
|
try {
|
|
$data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\Throwable $e) {
|
|
throw ValidationException::withMessages(['editorial_draft' => 'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']);
|
|
}
|
|
abort_unless(($data['schema_version'] ?? null) === 1 && is_array($data['fields'] ?? null), 409, 'Formato de preparação incompatível; os dados foram preservados.');
|
|
|
|
return $data;
|
|
}
|
|
|
|
public function workingVersion(InvestmentZone $zone): int
|
|
{
|
|
return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no);
|
|
}
|
|
|
|
public function nextVersion(InvestmentZone $zone): int
|
|
{
|
|
return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->max('version_no')) + 1;
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function management(InvestmentZone $zone): array
|
|
{
|
|
$draft = $this->draft($zone);
|
|
$workflow = $draft['editorial'] ?? null;
|
|
if ($workflow && $draft) {
|
|
$workflow['fresh'] = $this->freshReview($zone, $draft);
|
|
$workflow['independent_required'] = $this->independent($workflow);
|
|
$viewer = auth()->user();
|
|
$workflow['can_approve'] = ! $workflow['independent_required'] || ($viewer instanceof User && ! in_array($viewer->id, $workflow['contributors'], true));
|
|
}
|
|
|
|
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, 'editorial_enabled' => $this->editorialEnabled(), 'editorial_workflow' => $workflow,
|
|
'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no,
|
|
'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
|
|
'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
|
|
'details_available' => app(InvestmentZoneOperationalDetails::class)->installed()];
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $record
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function publicDetails(array $record): array
|
|
{
|
|
unset($record['editorial_draft'], $record['operational_details']);
|
|
foreach (['activities', 'resources'] as $key) {
|
|
if (isset($record[$key])) {
|
|
$record[$key] = array_map(fn ($item) => array_intersect_key($item, array_flip(self::DETAIL_PUBLIC)), $record[$key]);
|
|
}
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $after
|
|
* @param array<array-key, mixed> $before
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
private function mergeDetails(string $zoneId, array $before, array $after): array
|
|
{
|
|
$result = [];
|
|
foreach ($after as $item) {
|
|
$old = collect($before)->first(fn ($b) => isset($item['id']) ? ($b['id'] ?? null) === $item['id'] : ($b['name'] ?? null) === ($item['name'] ?? null));
|
|
$item = [...($old ?? []), ...$item];
|
|
$item['id'] ??= (string) Str::uuid();
|
|
if (isset($item['capacity_value']) && empty($item['capacity_unit'])) {
|
|
throw ValidationException::withMessages(['resources' => 'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']);
|
|
}
|
|
if (! empty($item['economic_activity_id'])) {
|
|
abort_unless(trim((string) ($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id', $item['economic_activity_id'])->where('data_classification', 'public')->whereNull('deleted_at')->exists(), 422, 'A correspondência CAE exige referência publicada e fundamento explícito.');
|
|
}
|
|
foreach ($item['support_document_ids'] ?? [] as $document) {
|
|
abort_unless(DB::table('investment_zone_attachments')->where('zone_id', $zoneId)->where('document_id', $document)->exists(), 422, 'Selecione suporte privado já associado a esta ficha.');
|
|
$d = DB::table('documents')->where('id', $document)->whereNull('deleted_at')->first();
|
|
abort_unless($d && $d->status === 'active' && $d->malware_scan_status === 'clean' && trim($d->sha256) === $d->malware_scanned_sha256, 422, 'Documento de suporte ainda não consultável.');
|
|
}
|
|
$result[] = $item;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** Called with zone row locked by InvestmentZones. Only this encrypted column is written.
|
|
* @param array<array-key, mixed> $data
|
|
*/
|
|
public function prepare(InvestmentZone $zone, array $data, int $actor): InvestmentZone
|
|
{
|
|
$this->ensureWriteMode($zone);
|
|
if ($this->editorialEnabled()) {
|
|
$this->reviewActor(User::findOrFail($actor));
|
|
}
|
|
abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.');
|
|
abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.');
|
|
$previous = $this->draft($zone);
|
|
$base = $previous['fields'] ?? array_intersect_key($zone->toArray(), array_flip(self::FIELDS));
|
|
$fields = [...$base, ...array_intersect_key($data, array_flip(self::FIELDS))];
|
|
$details = app(InvestmentZoneOperationalDetails::class);
|
|
if (array_key_exists('operational_details', $data)) {
|
|
$fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null);
|
|
} elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
|
|
$fields['operational_details'] = $details->recover($zone);
|
|
}
|
|
$details = app(InvestmentZoneOperationalDetails::class);
|
|
if (array_key_exists('operational_details', $data)) {
|
|
$fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null);
|
|
} elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
|
|
$fields['operational_details'] = $details->recover($zone);
|
|
}
|
|
foreach (['activities', 'resources'] as $key) {
|
|
if (array_key_exists($key, $data)) {
|
|
$fields[$key] = $this->mergeDetails($zone->id, $base[$key] ?? [], $data[$key]);
|
|
}
|
|
}
|
|
$draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no,
|
|
'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()];
|
|
if ($this->editorialEnabled()) {
|
|
$draft = $this->newReview($zone, $draft, $actor, $previous);
|
|
}
|
|
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]);
|
|
|
|
return $zone->fresh();
|
|
}
|
|
|
|
/** Apply only inside the publication transaction; a failed validation rolls everything back.
|
|
* @return array<array-key,mixed>|null
|
|
*/
|
|
public function promote(InvestmentZone $zone, int $version, ?int $actor = null): ?array
|
|
{
|
|
$draft = $this->draft($zone);
|
|
abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
|
|
if (! $draft) {
|
|
abort_if($this->editorialEnabled(), 409, 'Prepare e aprove a revisão antes de publicar.');
|
|
|
|
return null;
|
|
}
|
|
if ($this->editorialEnabled()) {
|
|
$this->lockReviewContext($zone, $draft);
|
|
$publisher = User::where('id', $actor)->firstOrFail();
|
|
$this->reviewActor($publisher);
|
|
$review = $draft['editorial'] ?? null;
|
|
abort_unless($review && $review['state'] === 'approved' && $this->freshReview($zone, $draft), 409, 'Aprovação desta edição necessária.');
|
|
$approval = $review['approval'];
|
|
abort_unless($approval && $approval['content_version'] === $review['content_version'] && hash_equals($approval['hash'], $review['hash']), 409, 'A aprovação não corresponde à edição.');
|
|
$reviewer = User::where('id', $approval['actor_id'])->first();
|
|
abort_unless($reviewer && $this->reviewAccess($reviewer), 403, 'Autoridade da decisão revogada.');
|
|
abort_if($this->independent($review) && (in_array($reviewer->id, $review['contributors'], true) || in_array($publisher->id, $review['contributors'], true)), 403, 'Esta edição exige aprovação/publicação independente.');
|
|
}
|
|
abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
|
|
$zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS)));
|
|
if (array_key_exists('operational_details', $draft['fields'])) {
|
|
$zone->setAttribute('operational_details', app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details']));
|
|
}
|
|
// Existing resource support remains in the encrypted snapshot; L1 private fields are durable.
|
|
foreach (['activities', 'resources'] as $key) {
|
|
$zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]);
|
|
}
|
|
$zone->setAttribute('editorial_draft', null);
|
|
|
|
return $draft['editorial'] ?? null;
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $draft */
|
|
private function lockReviewContext(InvestmentZone $zone, array $draft): void
|
|
{
|
|
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations'] as $field => $table) {
|
|
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
|
|
if ($id) {
|
|
DB::table($table)->where('id', $id)->lockForUpdate()->first();
|
|
}
|
|
}
|
|
$locations = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('geo_location_id')->all();
|
|
$locations[] = $draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id');
|
|
$all = [];
|
|
foreach (array_filter($locations) as $location) {
|
|
for ($i = 0; $location && $i < 5; $i++) {
|
|
$all[] = $location;
|
|
$location = DB::table('geo_locations')->where('id', $location)->value('parent_id');
|
|
}
|
|
}
|
|
DB::table('geo_locations')->whereIn('id', array_unique($all))->orderBy('id')->lockForUpdate()->get();
|
|
$documents = DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('document_id')->all();
|
|
DB::table('documents')->whereIn('id', $documents)->orderBy('id')->lockForUpdate()->get();
|
|
}
|
|
|
|
public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone
|
|
{
|
|
$this->ensureWriteMode($zone);
|
|
if ($this->editorialEnabled()) {
|
|
$this->reviewActor(User::findOrFail($actor));
|
|
}
|
|
$draft = $this->draft($zone);
|
|
abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
|
|
abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.');
|
|
$draft['base_public_version'] = $public;
|
|
$draft['working_version'] = $this->nextVersion($zone);
|
|
$draft['actor_id'] = $actor;
|
|
$draft['prepared_at'] = now()->toIso8601String();
|
|
if ($this->editorialEnabled()) {
|
|
$draft = $this->newReview($zone, $draft, $actor, $draft);
|
|
}
|
|
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]);
|
|
|
|
return $zone->fresh();
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $data
|
|
*/
|
|
public function snapshot(InvestmentZone $zone, int $version, int $actor, string $action, array $data): void
|
|
{
|
|
$json = json_encode($data, JSON_THROW_ON_ERROR);
|
|
$hash = hash('sha256', $json);
|
|
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version,
|
|
'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]);
|
|
$eventId = (string) Str::uuid();
|
|
$metadata = ['version_no' => $version, 'sha256' => $hash];
|
|
if ($this->editorialEnabled()) {
|
|
$metadata += ['editorial_workflow' => true, 'editorial_state' => $data['draft']['editorial']['state'] ?? ($action === 'published' ? 'published' : null)];
|
|
}
|
|
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
|
|
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
|
|
if ($this->editorialEnabled()) {
|
|
app(PortalEditorialNativeNotices::class)->emit(User::findOrFail($actor), $eventId, $data['draft']['editorial']['contributors'] ?? $data['decision']['contributors'] ?? []);
|
|
}
|
|
}
|
|
}
|