215 lines
15 KiB
PHP
215 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Models\User;
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/** Tempo's promotional disclosure, independent of investment execution approval. */
|
|
final class OpportunityPromotion
|
|
{
|
|
public const PURPOSE = 'promotional_preparation';
|
|
|
|
public const NOTICE = 'Em preparação. A divulgação não confirma financiamento, disponibilidade de terreno, licenças ou autorização de execução do investimento.';
|
|
|
|
public function allowed(User $actor, Opportunity $record): bool
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
|
|
|| $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) {
|
|
return false;
|
|
}
|
|
// Never overwrite a normal approved publication or its workflow decisions.
|
|
if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) {
|
|
return false;
|
|
}
|
|
$internal = $access->internalAssignment($actor, $record->opportunity_category);
|
|
|
|
return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id))
|
|
|| ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
|
|
&& $access->operational($actor, 'publish', $record->id));
|
|
}
|
|
|
|
private function authorize(User $actor, Opportunity $record): void
|
|
{
|
|
abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.');
|
|
if (! app(PortalProfileAccess::class)->mfa($actor)) {
|
|
throw new HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function editor(User $actor, Opportunity $record): array
|
|
{
|
|
abort_unless($this->allowed($actor, $record), 403);
|
|
$saved = data_get($record->workflow_context, 'promotional_summary', []);
|
|
// Only previously public text is recovered automatically. No private contact/document is copied.
|
|
$existing = data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE;
|
|
$form = $saved['input'] ?? ['title' => $existing ? $record->title : '', 'summary' => $existing ? $record->summary : '',
|
|
'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '',
|
|
'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [],
|
|
'sector_id' => $record->sector_id];
|
|
|
|
return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record),
|
|
'proactiveRelations' => app(ProactiveInvestment::class)->publicationRelations($record), 'proactiveKind' => app(OpportunityManagement::class)->values($record)['proactive']['kind'] ?? null, 'proactiveEnabled' => isset(app(OpportunityManagement::class)->values($record)['proactive']), 'proactiveEvidence' => array_values(array_filter(app(OpportunityManagement::class)->values($record)['proactive']['evidence'] ?? [], fn ($e) => $e['access'] === 'public')), 'proactiveOptions' => app(ProactiveInvestment::class)->options(), 'initial' => [...$form, ...($saved['input']['proactive_public'] ?? null ? ['proactive_public' => $saved['input']['proactive_public']] : [])], 'publicNow' => $existing && $record->status === 'published',
|
|
'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor),
|
|
'sectors' => Sector::catalog()->orderBy('name')->get(['id', 'name']),
|
|
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']),
|
|
'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')
|
|
->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])];
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $input
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function preview(User $actor, Opportunity $record, array $input): array
|
|
{
|
|
$this->authorize($actor, $record);
|
|
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'action' => 'required|in:disclose,withdraw',
|
|
'title' => 'required_if:action,disclose|nullable|string|max:255', 'summary' => 'required_if:action,disclose|nullable|string|max:499',
|
|
'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500',
|
|
'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d',
|
|
'proactive_public' => 'sometimes|array', 'proactive_reviewed' => 'sometimes|boolean',
|
|
'sector_id' => ['nullable', 'integer', Rule::in(Sector::catalog()->pluck('id')->all())],
|
|
'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
|
|
])->validate();
|
|
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.');
|
|
if ($data['action'] === 'withdraw') {
|
|
abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409);
|
|
}
|
|
$paths = [];
|
|
foreach ($data['territory_ids'] ?? [] as $id) {
|
|
$path = app(InvestmentGeography::class)->path($id, true);
|
|
abort_unless(count($path) > 0 && collect($path)->contains('id', $id), 422, 'Seleccione apenas territórios públicos existentes.');
|
|
$paths[] = $path;
|
|
}
|
|
$text = fn ($key) => trim(strip_tags($data[$key] ?? ''));
|
|
$body = implode("\n\n", array_filter([$text('description'), self::NOTICE,
|
|
'Limitações e lacunas: '.($text('limitations') ?: 'Informação ainda por completar.'),
|
|
'Fonte: '.($text('source_reference') ?: 'Não indicada.'), 'Edição: '.($text('edition') ?: 'Não indicada.'),
|
|
'Data-base: '.($data['source_date'] ?? 'Não indicada.')]));
|
|
$body = OpportunityPublicText::description($record, $body);
|
|
$projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body,
|
|
'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE,
|
|
'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)];
|
|
if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) {
|
|
$projection['proactive'] = $proactive;
|
|
$projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]);
|
|
}
|
|
if ($data['action'] === 'disclose') {
|
|
abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.');
|
|
}
|
|
if ($data['action'] === 'withdraw') {
|
|
$projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])];
|
|
}
|
|
$payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'],
|
|
'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp];
|
|
|
|
return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
|
|
'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false];
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $input
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function confirm(User $actor, int $id, array $input): array
|
|
{
|
|
$data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid',
|
|
'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate();
|
|
try {
|
|
$preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR);
|
|
} catch (\Throwable) {
|
|
abort(422, 'Prévia inválida. Prepare outra prévia.');
|
|
}
|
|
abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403);
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data, $preview) {
|
|
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]);
|
|
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
$this->authorize($actor, $record);
|
|
$digest = hash('sha256', $data['token'].'|'.$data['reason']);
|
|
$replay = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')
|
|
->where('after_data->operation_key', $data['operation_key'])->first();
|
|
if ($replay) {
|
|
$prior = json_decode($replay->after_data, true);
|
|
abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.');
|
|
|
|
return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.'];
|
|
}
|
|
abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.');
|
|
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $preview['etag']), 409, 'Alteração concorrente: a ficha foi preservada. Reabra e reveja a nova versão.');
|
|
$before = $record->getRawOriginal();
|
|
$oldLocations = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')->all();
|
|
$context = $record->workflow_context ?? [];
|
|
if ($preview['action'] === 'withdraw') {
|
|
$record->status = 'suspended';
|
|
$record->published_at = null;
|
|
$context['publication']['visibility'] = 'withdrawn';
|
|
$action = 'promotional_summary_withdrawn';
|
|
} else {
|
|
$p = $preview['projection'];
|
|
if ($p['sector_id'] !== null) {
|
|
abort_unless(Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.');
|
|
}
|
|
$paths = [];
|
|
foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
|
|
$path = app(InvestmentGeography::class)->path($territory, true);
|
|
abort_unless(count($path) && collect($path)->contains('id', $territory), 409, 'Um território deixou de estar disponível. Prepare outra prévia.');
|
|
$paths[] = $path;
|
|
}
|
|
$currentTerritories = array_map(fn ($path) => ['id' => end($path)->id, 'label' => implode(' / ', array_column($path, 'name'))], $paths);
|
|
abort_unless($currentTerritories === $p['territories'], 409, 'O enquadramento territorial mudou. Prepare outra prévia.');
|
|
// Preserve the original private draft bytes; retain its former base
|
|
// columns in the existing hidden context before projecting public text.
|
|
$context['promotional_private_base'] ??= $record->only(OpportunityManagement::FIELDS);
|
|
$context['working_state'] ??= app(PortalProfileAccess::class)->state($record);
|
|
$context['promotional_summary'] = ['input' => $preview['input'], 'projection' => $p];
|
|
$context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id,
|
|
'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true];
|
|
$action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published';
|
|
foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) {
|
|
$record->{$field} = $p[$field];
|
|
}
|
|
foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) {
|
|
$record->{$field} = null;
|
|
}
|
|
$main = $paths[0] ?? [];
|
|
$record->primary_territory_id = $main ? end($main)->id : null;
|
|
$record->province = collect($main)->firstWhere('location_type', 'province')?->name;
|
|
$record->district = collect($main)->firstWhere('location_type', 'district')?->name;
|
|
$record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified');
|
|
DB::table('opportunity_locations')->where('opportunity_id', $id)->delete();
|
|
foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
|
|
DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]);
|
|
}
|
|
$record->status = 'published';
|
|
$record->is_featured = false;
|
|
$record->last_validated_at = null;
|
|
$record->published_at = now();
|
|
}
|
|
$record->workflow_context = $context;
|
|
$record->management_version++;
|
|
$record->save();
|
|
DB::table('audit_events')->insert(['source_system' => 'tempo-preparation-publication', 'entity_type' => 'opportunity',
|
|
'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
|
|
'before_data' => json_encode(['record_id' => $id, 'record' => $before, 'territories' => $oldLocations], JSON_THROW_ON_ERROR),
|
|
'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest,
|
|
'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false,
|
|
'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]);
|
|
|
|
return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.'];
|
|
});
|
|
}
|
|
}
|