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

120 lines
6.9 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;
/** Phase 1 adapter: the existing opportunity owns identity, versions 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 private submission only: no editorial task, approval or publication.
* @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();
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_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;
});
}
/** @return array<string,mixed> */
public function presentation(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
return ['id' => $record->id, 'reference' => $record->reference, 'values' => app(OpportunityManagement::class)->values($record),
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => data_get($record->workflow_context, 'private_offer_preparation.state'),
'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id,
'territory' => app(InvestmentGeography::class)->path($record->primary_territory_id, true)];
}
}