120 lines
6.9 KiB
PHP
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)];
|
|
}
|
|
}
|