271 lines
18 KiB
PHP
271 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class OpportunityManagement
|
|
{
|
|
public const CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise', 'concession', 'financing', 'supply', 'other'];
|
|
|
|
public const FIELDS = ['title', 'summary', 'description', 'sector_id', 'promoter_name', 'promoter_type', 'maturity_level', 'maturity_label', 'financing_modality', 'expected_impact', 'estimated_investment_amount', 'estimated_investment_currency', 'opportunity_category', 'primary_territory_id', 'province', 'district', 'geography_scope'];
|
|
|
|
public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version'];
|
|
|
|
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical'];
|
|
|
|
/** @return array<string,mixed> */
|
|
public function values(Opportunity $record): array
|
|
{
|
|
$base = $record->only(self::FIELDS);
|
|
$draft = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
|
|
|
|
return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::PRIVATE_DRAFT_FIELDS)))];
|
|
}
|
|
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasColumn('opportunities', 'management_version');
|
|
}
|
|
|
|
public function etag(Opportunity $record): string
|
|
{
|
|
return hash('sha256', json_encode($record->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
public function closed(Opportunity $record): bool
|
|
{
|
|
return $record->first_submitted_at !== null || $record->management_closed_once || ! in_array($record->status, ['draft'], true);
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function save(User $actor, array $input, ?int $id = null): Opportunity
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
$profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades');
|
|
if ($id !== null) {
|
|
$existingRecord = Opportunity::withTrashed()->findOrFail($id);
|
|
$access->authorize($actor, 'edit', $existingRecord);
|
|
if ($existingRecord->owner_user_id !== null) {
|
|
abort_unless($access->canEdit($actor, $existingRecord, $input['opportunity_category'] ?? null), 403);
|
|
abort_if(array_key_exists('organization_id', $input) && $input['organization_id'] !== $existingRecord->organization_id, 403, 'A organização não pode ser alterada por edição.');
|
|
}
|
|
} elseif ($profileCreation) {
|
|
abort_unless($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? ''), 403);
|
|
} else {
|
|
abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.');
|
|
}
|
|
abort_unless($this->installed(), 503, 'Instalação autenticada ainda necessária.');
|
|
// Canonical common fields are authoritative. The editor may echo legacy
|
|
// mirrors, but they must not impose a second, incompatible contract.
|
|
if (is_array($input['canonical'] ?? null)) {
|
|
foreach (['official_title' => 'title', 'executive_summary' => 'summary', 'demand_statement' => 'description', 'sector_id' => 'sector_id', 'promoter_type' => 'promoter_type'] as $source => $target) {
|
|
if (array_key_exists($source, $input['canonical'])) {
|
|
unset($input[$target]);
|
|
}
|
|
}
|
|
}
|
|
$data = Validator::make($input, [
|
|
'etag' => [$id ? 'required' : 'nullable', 'string', 'size:64'],
|
|
'creation_key' => [$id ? 'nullable' : 'required', 'uuid'],
|
|
'title' => ['nullable', 'string', 'max:150'], 'summary' => ['nullable', 'string', 'max:500'],
|
|
'description' => ['nullable', 'string', 'max:50000'], 'promoter_name' => ['nullable', 'string', 'max:200'],
|
|
'promoter_type' => ['nullable', Rule::in(['public', 'private', 'ppp', 'cooperative', 'association', 'community', 'other', $id ? Opportunity::withTrashed()->findOrFail($id)->promoter_type : null])],
|
|
'maturity_level' => ['nullable', 'integer', 'between:1,8'],
|
|
'maturity_stage' => ['nullable', Rule::in(array_keys(OpportunityMaturity::STAGES))],
|
|
'maturity_catalogue_version' => ['nullable', Rule::in([OpportunityMaturity::VERSION, OpportunityMaturity::LEGACY])],
|
|
'financing_modality' => ['nullable', 'string', 'max:120'],
|
|
'expected_impact' => ['nullable', 'string', 'max:10000'],
|
|
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
|
|
'opportunity_category' => ['nullable', Rule::in(self::CATEGORIES)],
|
|
'real_estate' => ['sometimes', 'array'],
|
|
'canonical' => ['sometimes', 'array'],
|
|
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
|
|
'estimated_investment_currency' => ['nullable', 'in:MZN,USD,EUR,ZAR'],
|
|
'reason' => ['required', 'string', 'min:20', 'max:1000'],
|
|
'organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
|
'owner_user_id' => 'prohibited', 'workflow_context' => 'prohibited', 'status' => 'prohibited', 'first_submitted_at' => 'prohibited',
|
|
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
|
])->validate();
|
|
$data = app(OpportunityMaturity::class)->normalize($data);
|
|
if (array_key_exists('real_estate', $data)) {
|
|
$data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']);
|
|
}
|
|
if (array_key_exists('canonical', $data)) {
|
|
$data['canonical'] = app(OpportunityCanonicalContract::class)->draft($data['canonical']);
|
|
}
|
|
|
|
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access) {
|
|
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
|
|
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
|
|
$access->authorize($actor, 'view', $existing);
|
|
abort_unless($profileCreation === ($existing->owner_user_id !== null) && (! $profileCreation || $existing->organization_id === ($data['organization_id'] ?? null)), 409);
|
|
$snapshot = $this->values($existing);
|
|
foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])) as $key => $value) {
|
|
if (($snapshot[$key] ?? null) != $value) {
|
|
throw ValidationException::withMessages(['creation_key' => 'Esta chave já foi utilizada com dados diferentes.']);
|
|
}
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
|
|
if ($id) {
|
|
$access->authorize($actor, 'edit', $record);
|
|
$this->check($record, $data['etag']);
|
|
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
|
|
}
|
|
$before = $record->getAttributes();
|
|
$previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : [];
|
|
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS]));
|
|
if (array_key_exists('canonical', $changes)) {
|
|
$previous = $record->exists ? $this->values($record) : [];
|
|
$changes['canonical'] = [...($previous['canonical'] ?? []), ...$changes['canonical']];
|
|
app(OpportunityClassification::class)->validate($changes['canonical'], $record);
|
|
if (($changes['canonical']['opportunity_category'] ?? ($changes['opportunity_category'] ?? $record->opportunity_category)) !== ($changes['opportunity_category'] ?? $record->opportunity_category)) {
|
|
throw ValidationException::withMessages(['canonical.opportunity_category' => 'Contrato e registo devem usar a mesma categoria.']);
|
|
}
|
|
// One entry of common data feeds the existing fields; a closed/public version stays untouched.
|
|
foreach (['official_title' => 'title', 'executive_summary' => 'summary', 'demand_statement' => 'description', 'sector_id' => 'sector_id', 'promoter_type' => 'promoter_type'] as $source => $target) {
|
|
if (array_key_exists($source, $changes['canonical'])) {
|
|
$value = $changes['canonical'][$source];
|
|
// Preserve the full text in canonical JSON; the legacy
|
|
// summary column is only a bounded presentation mirror.
|
|
$changes[$target] = $target === 'summary' && is_string($value) ? Str::limit($value, 499, '…') : $value;
|
|
}
|
|
}
|
|
}
|
|
if (array_key_exists('real_estate', $changes)) {
|
|
$previousValues = $record->exists ? $this->values($record) : [];
|
|
$category = array_key_exists('opportunity_category', $data) ? $data['opportunity_category'] : ($previousValues['opportunity_category'] ?? null);
|
|
if ($category !== 'real_estate') {
|
|
throw ValidationException::withMessages(['real_estate' => 'O contrato imobiliário pertence à categoria real_estate.']);
|
|
}
|
|
$changes['real_estate'] = [...($previousValues['real_estate'] ?? []), ...$changes['real_estate']];
|
|
}
|
|
if (array_intersect_key($data, InvestmentGeography::FIELDS) !== []) {
|
|
$path = app(InvestmentGeography::class)->selection($data, true);
|
|
$changes['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
|
$changes['province'] = $path[0]->name ?? null;
|
|
$changes['district'] = $path[1]->name ?? null;
|
|
$changes['geography_scope'] = $path === [] ? 'unspecified' : 'specific';
|
|
}
|
|
if (! $id) {
|
|
$record->reference = 'INV-MZ-GEN-'.random_int(1000000000000000, 9999999999999999);
|
|
$record->slug = strtolower($record->reference);
|
|
$record->status = 'draft';
|
|
$record->management_creation_key = $data['creation_key'];
|
|
$record->management_version = 1;
|
|
$record->estimated_investment_currency = null;
|
|
if ($profileCreation) {
|
|
abort_unless($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? ''), 403);
|
|
$record->owner_user_id = $actor->id;
|
|
$record->organization_id = $data['organization_id'] ?? null;
|
|
$record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]];
|
|
if (($internal=$access->internalAssignment($actor,$data['opportunity_category'] ?? '')) !== null) {
|
|
$record->workflow_context=[...$record->workflow_context,'operator_organization_id'=>$internal->organization_id,'internal_assignment_id'=>$internal->id];
|
|
}
|
|
}
|
|
} else {
|
|
$record->management_version++;
|
|
}
|
|
if ($id && $this->closed($record)) {
|
|
$record->management_closed_once = true;
|
|
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS);
|
|
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
|
|
} else {
|
|
foreach ($changes as $key => $value) {
|
|
if (in_array($key, ['real_estate', 'canonical', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
|
|
continue;
|
|
}
|
|
$record->{$key} = $value;
|
|
}
|
|
if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes)) {
|
|
// The existing draft JSON holds the versioned catalogue; legacy columns stay intact.
|
|
$previous = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
|
|
$record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::PRIVATE_DRAFT_FIELDS))], JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|
|
if ($record->owner_user_id !== null) {
|
|
$context = $record->workflow_context ?? [];
|
|
unset($context['approval']);
|
|
$context['content_revision'] = ($context['content_revision'] ?? 0) + 1;
|
|
$context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id]));
|
|
$context['canonical_items'] = app(OpportunityCanonicalMetadata::class)->mutate(
|
|
$previousCanonical, $this->values($record)['canonical'] ?? [], $context['canonical_items'] ?? [], $actor, $data['reason']);
|
|
$record->workflow_context = $context;
|
|
}
|
|
$record->save();
|
|
$this->audit($record, $actor, $before, $id ? 'draft_updated' : 'draft_created', $data['reason']);
|
|
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function lifecycle(User $actor, int $id, array $input): Opportunity
|
|
{
|
|
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($actor), 403);
|
|
$actions = config('portal.opportunity_dashboard_access_enabled') ? 'in:delete,archive,restore' : 'in:delete,restore';
|
|
$data = Validator::make($input, ['etag' => ['required', 'string', 'size:64'], 'action' => ['required', $actions], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'confirm' => ['required', 'accepted']])->validate();
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data) {
|
|
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
|
|
abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.');
|
|
$this->check($record, $data['etag']);
|
|
$before = $record->getAttributes();
|
|
if ($data['action'] === 'restore') {
|
|
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
|
|
if ($record->sector_id) {
|
|
Validator::make(['sector_id' => $record->sector_id], ['sector_id' => [Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)]])->validate();
|
|
}
|
|
$record->deleted_at = null;
|
|
$record->status = 'draft';
|
|
$record->published_at = null;
|
|
if ($this->closed($record) && ! $record->management_draft) {
|
|
$record->management_draft = json_encode($record->only(self::FIELDS), JSON_THROW_ON_ERROR);
|
|
}
|
|
} else {
|
|
abort_if($record->trashed() || $record->status === 'archived', 409, 'Registo já eliminado/arquivado.');
|
|
if ($data['action'] === 'archive' || $this->closed($record)) {
|
|
if ($this->closed($record)) {
|
|
$record->management_closed_once = true;
|
|
}
|
|
$record->status = 'archived';
|
|
} else {
|
|
$record->deleted_at = now();
|
|
}
|
|
}
|
|
$record->management_version++;
|
|
$record->save();
|
|
$this->audit($record, $actor, $before, $data['action'], $data['reason']);
|
|
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
private function check(Opportunity $record, string $etag): void
|
|
{
|
|
if (! hash_equals($this->etag($record), $etag)) {
|
|
throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['O registo mudou. Os dados preenchidos foram preservados; abra a versão atual noutra aba antes de reconciliar.']]], 409));
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $before */
|
|
private function audit(Opportunity $record, User $actor, array $before, string $action, string $reason): void
|
|
{
|
|
DB::table('audit_events')->insert(['source_system' => 'opportunity-management', 'entity_type' => 'opportunities', 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
|
|
'before_data' => json_encode(['record_id' => $record->id, 'snapshot' => Crypt::encryptString(json_encode($before, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR),
|
|
'after_data' => json_encode(['record_id' => $record->id, 'version' => $record->management_version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($record->getAttributes(), JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|