Files
Investidor-V2/app/Services/PrivateOfferPreparation.php
T

227 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Private commercial adapter: the existing opportunity owns identity, workflow and audit. */
final class PrivateOfferPreparation
{
/** @return array<string,mixed> */
public function validate(mixed $input, bool $complete = false): array
{
$validated = Validator::make(['private_offer' => $input], [
'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property',
'private_offer.price_mode' => 'nullable|in:amount,negotiable',
'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'],
'private_offer.contact_id' => 'nullable|uuid',
'private_offer.business' => 'sometimes|nullable|array',
'private_offer.property' => 'sometimes|nullable|array',
])->validate()['private_offer'];
return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property']))));
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?int $id = null): Opportunity
{
foreach (['title', 'description'] as $field) {
if (is_string($input[$field] ?? null)) {
$input[$field] = trim($input[$field]);
}
}
$allowed = ['creation_key', 'etag', 'reason', 'title', 'description', 'opportunity_category', 'organization_id', 'private_offer', ...array_keys(InvestmentGeography::FIELDS)];
if (array_diff(array_keys($input), $allowed)) {
throw ValidationException::withMessages(['offer' => 'O pedido contém campos fora da preparação privada.']);
}
Validator::make($input, ['opportunity_category' => ['sometimes', 'required', Rule::in(OpportunityManagement::CATEGORIES)]])->validate();
return DB::transaction(function () use ($actor, $input, $id) {
$record = app(OpportunityManagement::class)->save($actor, $input, $id, true);
$values = app(OpportunityManagement::class)->values($record);
app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']);
$contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null;
if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']);
}
return $record;
});
}
public function record(User $actor, int $id, bool $edit = false): Opportunity
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
app(PortalProfileAccess::class)->authorize($actor, $edit ? 'edit' : 'view', $record);
return $record;
}
/** Register submission through the existing editorial engine when available.
* @param array<string,mixed> $input */
public function submit(User $actor, int $id, array $input): Opportunity
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
if (app(PortalEditorialWorkflow::class)->available()) {
$record = $this->record($actor, $id);
$access = app(PortalProfileAccess::class);
if ($access->state($record) === 'submitted') {
abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $id), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
return $record;
}
return app(OpportunityProfileWorkflow::class)->transition($actor, $id, [...$data, 'action' => 'submit', 'reason' => 'Submissão privada da oferta conferida pelo promotor.']);
}
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
abort_unless($record->isPrivateOfferPreparation(), 404);
$access = app(PortalProfileAccess::class);
$access->authorize($actor, 'view', $record);
abort_if(($record->workflow_context['private_offer_preparation']['version'] ?? 1) >= 2, 503, 'O circuito editorial está indisponível. A versão e o histórico foram preservados; não é permitida a retoma pelo percurso anterior.');
abort_unless($access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $record->id), 403);
$management = app(OpportunityManagement::class);
abort_unless(hash_equals($management->etag($record), $data['etag']), 409, 'A oferta mudou. Recupere a versão actual antes de submeter.');
if (data_get($record->workflow_context, 'private_offer_preparation.state') === 'submitted') {
return $record;
}
$access->authorize($actor, 'edit', $record);
$values = $management->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$this->validate($values['private_offer'], true);
if (! app(OpportunityContacts::class)->related($record, $values['private_offer']['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
// Recheck revoked grants and representation inside the same transaction.
abort_unless($access->canCreate($actor, $record->organization_id, $record->opportunity_category), 403);
$path = app(InvestmentGeography::class)->path($record->primary_territory_id, true);
if ($record->primary_territory_id && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes de submeter.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$context = $record->workflow_context;
$context['private_offer_preparation'] = ['version' => 1, 'state' => 'submitted', 'submitted_by' => $actor->id,
'submitted_at' => now()->toIso8601String(), 'submitted_revision' => $record->management_version];
$record->workflow_context = $context;
$record->management_version++;
$record->save();
$access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]);
return $record;
});
}
/** Adapter of the five common fields; never relaxes the full canonical contract.
* @return array<string,mixed> */
public function snapshot(Opportunity $record): array
{
$values = app(OpportunityManagement::class)->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$offer = $this->validate($values['private_offer'], true);
app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']);
$resources = app(PrivateOfferDetails::class)->resources($record);
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
$contact = DB::table('contacts')->where('id', $offer['contact_id'])->first();
$path = app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true);
if (! empty($values['primary_territory_id']) && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes da decisão.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
$extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== [];
if ($extended) {
$payload['resources'] = $resources;
}
$normalize = function (mixed $value) use (&$normalize): mixed {
if (is_object($value)) {
$value = get_object_vars($value);
}
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
return array_map($normalize, $value);
};
$payload = $normalize($payload);
$contract = $extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2';
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
}
/** @return array<string,mixed> */
public function presentation(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$values = app(OpportunityManagement::class)->values($record);
$fresh = null;
$contactId = $values['private_offer']['contact_id'] ?? null;
$contactLabel = $contactId && app(OpportunityContacts::class)->related($record, $contactId)
? DB::table('contacts')->where('id', $contactId)->value('full_name') : null;
if (isset($record->workflow_context['approval'])) {
try {
$fresh = hash_equals($record->workflow_context['approval']['hash'], $this->snapshot($record)['hash']);
} catch (ValidationException $error) {
$fresh = false;
}
}
$history = $record->workflow_context['events'] ?? [];
$names = User::whereIn('id', array_filter([...array_column($history, 'actor_id'), ...array_column($history, 'assignee_user_id')]))->pluck('name', 'id');
$history = array_map(fn ($event) => [...$event, 'actor_name' => $names[$event['actor_id']] ?? 'Interveniente indisponível',
'assignee_name' => isset($event['assignee_user_id']) ? ($names[$event['assignee_user_id']] ?? 'Interveniente indisponível') : null], $history);
$versions = [...($record->workflow_context['decision_history'] ?? []), ...($record->workflow_context['submission_history'] ?? [])];
if (isset($record->workflow_context['submission'])) {
$versions[] = ['submission' => $record->workflow_context['submission'], 'approval' => $record->workflow_context['approval'] ?? null,
'rejection' => $record->workflow_context['rejection'] ?? null];
}
$uniqueVersions = [];
foreach ($versions as $version) {
if (! isset($version['submission']['payload']['private_offer'])) {
continue;
}
$key = $version['submission']['record_version'].':'.$version['submission']['hash'].':'.($version['submission']['review_cycle'] ?? 0);
$previous = $uniqueVersions[$key] ?? [];
$uniqueVersions[$key] = ['submission' => $version['submission'], 'approval' => $version['approval'] ?? $previous['approval'] ?? null,
'rejection' => $version['rejection'] ?? $previous['rejection'] ?? null];
}
$workflow = app(OpportunityProfileWorkflow::class);
$actions = $workflow->actions($actor, $record);
return ['id' => $record->id, 'reference' => $record->reference, 'values' => $values,
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => app(PortalProfileAccess::class)->state($record),
'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id,
'actions' => $actions,
'reviewers' => in_array('assign_review', $actions, true) ? $workflow->participants($actor, $record)['validators'] : [],
'reauthenticationRequired' => app(OpportunityProfileWorkflow::class)->reauthenticationRequired($actor, $record),
'history' => $history, 'submittedVersions' => array_values($uniqueVersions), 'correctionItems' => $record->workflow_context['correction_items'] ?? [],
'approval' => $record->workflow_context['approval'] ?? null, 'approvalFresh' => $fresh, 'contactLabel' => $contactLabel, 'tasks' => app(OpportunityWorkflowTasks::class)->listing($record),
'territory' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true)];
}
}