213 lines
13 KiB
PHP
213 lines
13 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
|
|
{
|
|
return Validator::make(['private_offer' => $input], [
|
|
'private_offer' => 'present|array:price_mode,amount,currency,contact_id',
|
|
'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',
|
|
])->validate()['private_offer'];
|
|
}
|
|
|
|
/** @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);
|
|
$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);
|
|
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];
|
|
$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);
|
|
|
|
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => 'private-offer-editorial-v2', 'payload' => $payload], JSON_THROW_ON_ERROR)),
|
|
'record_version' => $record->management_version, 'contract_version' => 'private-offer-editorial-v2', 'resources' => ['documents' => [], 'images' => [], 'locations' => []]];
|
|
}
|
|
|
|
/** @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)];
|
|
}
|
|
}
|