feat: prepare private offers through existing opportunities
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PrivateOfferPreparation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class PrivateOfferPreparationController extends Controller
|
||||
{
|
||||
public function index(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
$records = $access->scope(Opportunity::query(), $request->user())->whereNotNull('workflow_context->private_offer_preparation')
|
||||
->orderByDesc('id')->paginate(20);
|
||||
|
||||
return Inertia::render('offers/preparation-index', ['records' => $records->through(fn ($r) => [
|
||||
'id' => $r->id, 'title' => $r->title, 'reference' => $r->reference,
|
||||
'state' => data_get($r->workflow_context, 'private_offer_preparation.state'),
|
||||
]), 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, PrivateOfferPreparation $service, ?int $id = null): Response|JsonResponse
|
||||
{
|
||||
$record = $id ? $service->record($request->user(), $id) : null;
|
||||
$contexts = app(PortalProfileAccess::class)->navigation($request->user())['contexts'];
|
||||
abort_unless($record || count($contexts) > 0, 403, 'É necessária uma atribuição de promotor no âmbito da oferta.');
|
||||
$presentation = $record ? $service->presentation($request->user(), $record) : null;
|
||||
if ($request->expectsJson() && ! $request->header('X-Inertia')) {
|
||||
return response()->json(['record' => $presentation]);
|
||||
}
|
||||
$options = app(OpportunityCanonicalContract::class)->options($request->user(), $record);
|
||||
|
||||
return Inertia::render('offers/preparation', ['record' => $presentation, 'contexts' => $contexts,
|
||||
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors']]);
|
||||
}
|
||||
|
||||
public function save(Request $request, PrivateOfferPreparation $service, ?int $id = null): JsonResponse
|
||||
{
|
||||
if ($id) {
|
||||
$service->record($request->user(), $id, true);
|
||||
}
|
||||
$record = $service->save($request->user(), $request->all(), $id);
|
||||
|
||||
return response()->json(['record' => $service->presentation($request->user(), $record)], $id ? 200 : 201);
|
||||
}
|
||||
|
||||
public function submit(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
|
||||
{
|
||||
$record = $service->submit($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['record' => $service->presentation($request->user(), $record),
|
||||
'message' => 'Submissão privada registada. A oferta não foi publicada.']);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ class ProtectProfileOpportunity
|
||||
}
|
||||
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$record = Opportunity::withTrashed()->find($id);
|
||||
if ($record?->isPrivateOfferPreparation()) {
|
||||
app(PortalProfileAccess::class)->authorize($request->user(), $request->isMethod('GET') ? 'view' : 'edit', $record);
|
||||
if ($request->isMethod('GET') && $request->is('minhas-oportunidades/*/edit', 'minhas-oportunidades/*/view', 'management/opportunities/*/edit', 'management/opportunities/*/view')) {
|
||||
return redirect('/minhas-oportunidades/ofertas/'.$record->id)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
// Contact registration reuses the CRM master and its fresh-MFA gate.
|
||||
abort_unless($request->isMethod('POST') && $request->is('minhas-oportunidades/*/contacts'), 403, 'Use a preparação privada da oferta.');
|
||||
}
|
||||
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
|
||||
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
|
||||
}
|
||||
|
||||
@@ -30,6 +30,23 @@ class Opportunity extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public function isPrivateOfferPreparation(): bool
|
||||
{
|
||||
return isset($this->workflow_context['private_offer_preparation']);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $record) {
|
||||
$original = $record->getRawOriginal('workflow_context');
|
||||
$wasPrivate = is_string($original) && isset(json_decode($original, true)['private_offer_preparation']);
|
||||
abort_if($wasPrivate && ! $record->isPrivateOfferPreparation(), 403, 'A preparação privada não pode ser convertida neste percurso.');
|
||||
if ($record->isPrivateOfferPreparation()) {
|
||||
abort_unless($record->status === 'draft' && $record->published_at === null, 403, 'A publicação de Ofertas não está disponível nesta fase.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once', 'owner_user_id', 'organization_id', 'workflow_context'];
|
||||
|
||||
protected $appends = ['maturity_catalogue_version'];
|
||||
@@ -115,6 +132,7 @@ class Opportunity extends Model
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
if (Schema::hasColumn('opportunities', 'workflow_context')) {
|
||||
$query->whereNull('workflow_context->private_offer_preparation');
|
||||
$query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public'));
|
||||
}
|
||||
|
||||
|
||||
@@ -279,14 +279,7 @@ class OpportunityCanonicalContract
|
||||
'economic_activity_id', 'Sector, subsector e atividade devem corresponder à hierarquia/versão publicada; os cinco níveis originais são conservados.');
|
||||
foreach (['internal_contact_id', 'public_contact_id'] as $field) {
|
||||
if (! empty($payload[$field])) {
|
||||
$contacts = DB::table('contacts')->where('contacts.id', $payload[$field])->whereNull('contacts.deleted_at');
|
||||
if ($record->organization_id) {
|
||||
$contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id')
|
||||
->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at'));
|
||||
} else {
|
||||
$contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id);
|
||||
}
|
||||
$check($contacts->exists(), $field, 'Contacto inexistente ou sem relação com o Promotor.');
|
||||
$check(app(OpportunityContacts::class)->related($record, $payload[$field]), $field, 'Contacto inexistente ou sem relação com o Promotor.');
|
||||
}
|
||||
}
|
||||
$resources = [];
|
||||
|
||||
@@ -14,6 +14,19 @@ use Illuminate\Validation\ValidationException;
|
||||
/** Reuse the CRM master without manufacturing an interest, lead or consent. */
|
||||
class OpportunityContacts
|
||||
{
|
||||
public function related(Opportunity $record, string $id): bool
|
||||
{
|
||||
$contacts = DB::table('contacts')->where('contacts.id', $id)->whereNull('contacts.deleted_at');
|
||||
if ($record->organization_id) {
|
||||
$contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id')
|
||||
->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at'));
|
||||
} else {
|
||||
$contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id);
|
||||
}
|
||||
|
||||
return $contacts->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $input
|
||||
* @return array<array-key, mixed>
|
||||
|
||||
@@ -21,7 +21,7 @@ class OpportunityManagement
|
||||
|
||||
public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version'];
|
||||
|
||||
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical', 'proactive'];
|
||||
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical', 'proactive', 'private_offer'];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function values(Opportunity $record): array
|
||||
@@ -51,12 +51,13 @@ class OpportunityManagement
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input, ?int $id = null): Opportunity
|
||||
public function save(User $actor, array $input, ?int $id = null, bool $privateOffer = false): Opportunity
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades');
|
||||
if ($id !== null) {
|
||||
$existingRecord = Opportunity::withTrashed()->findOrFail($id);
|
||||
abort_if($existingRecord->isPrivateOfferPreparation() && ! $privateOffer, 403, 'Use a preparação privada da oferta.');
|
||||
$access->authorize($actor, 'edit', $existingRecord);
|
||||
if ($existingRecord->owner_user_id !== null) {
|
||||
abort_unless($access->canEdit($actor, $existingRecord, $input['opportunity_category'] ?? null), 403);
|
||||
@@ -100,6 +101,9 @@ class OpportunityManagement
|
||||
'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();
|
||||
if ($privateOffer && ($id === null || array_key_exists('private_offer', $input))) {
|
||||
$data['private_offer'] = app(PrivateOfferPreparation::class)->validate($input['private_offer'] ?? []);
|
||||
}
|
||||
$data = app(OpportunityMaturity::class)->normalize($data);
|
||||
if (array_key_exists('real_estate', $data)) {
|
||||
$data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']);
|
||||
@@ -108,10 +112,11 @@ class OpportunityManagement
|
||||
$data['canonical'] = app(OpportunityCanonicalContract::class)->draft($data['canonical']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access) {
|
||||
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access, $privateOffer) {
|
||||
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($existing->isPrivateOfferPreparation() === $privateOffer, 409);
|
||||
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) {
|
||||
@@ -124,6 +129,7 @@ class OpportunityManagement
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
|
||||
if ($id) {
|
||||
abort_unless($record->isPrivateOfferPreparation() === $privateOffer, 409);
|
||||
$access->authorize($actor, 'edit', $record);
|
||||
$this->check($record, $data['etag']);
|
||||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
|
||||
@@ -191,12 +197,12 @@ class OpportunityManagement
|
||||
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
foreach ($changes as $key => $value) {
|
||||
if (in_array($key, ['real_estate', 'canonical', 'proactive', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
|
||||
if (in_array($key, ['real_estate', 'canonical', 'proactive', 'private_offer', '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) || array_key_exists('proactive', $changes)) {
|
||||
if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes) || array_key_exists('proactive', $changes) || array_key_exists('private_offer', $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);
|
||||
@@ -204,6 +210,9 @@ class OpportunityManagement
|
||||
}
|
||||
if ($record->owner_user_id !== null) {
|
||||
$context = $record->workflow_context ?? [];
|
||||
if ($privateOffer) {
|
||||
$context['private_offer_preparation'] ??= ['version' => 1, 'state' => 'draft'];
|
||||
}
|
||||
unset($context['approval']);
|
||||
$context['content_revision'] = ($context['content_revision'] ?? 0) + 1;
|
||||
$context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id]));
|
||||
|
||||
@@ -24,7 +24,7 @@ class OpportunityProfileWorkflow
|
||||
/** @return list<string> */
|
||||
public function actions(User $actor, Opportunity $record): array
|
||||
{
|
||||
if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
|
||||
if ($record->isPrivateOfferPreparation() || $record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
|
||||
return [];
|
||||
}
|
||||
$actions = [];
|
||||
@@ -520,6 +520,7 @@ class OpportunityProfileWorkflow
|
||||
|
||||
private function authorizeAction(User $actor, Opportunity $record, string $action): void
|
||||
{
|
||||
abort_if($record->isPrivateOfferPreparation(), 403, 'A oferta está em preparação privada; o circuito editorial não está disponível nesta fase.');
|
||||
$this->access->authorize($actor, 'view', $record);
|
||||
$promoter = $this->access->promoterRecordScope($actor, $record)
|
||||
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
|
||||
|
||||
@@ -22,7 +22,7 @@ final class OpportunityPromotion
|
||||
public function allowed(User $actor, Opportunity $record): bool
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
|
||||
if ($record->isPrivateOfferPreparation() || $record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
|
||||
|| $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -241,6 +241,9 @@ class PortalProfileAccess
|
||||
|
||||
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool
|
||||
{
|
||||
if ($record->isPrivateOfferPreparation() && data_get($record->workflow_context, 'private_offer_preparation.state') !== 'draft') {
|
||||
return false;
|
||||
}
|
||||
if ($record->owner_user_id === null) {
|
||||
return $this->legacyWriteAllowed($actor);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?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)];
|
||||
}
|
||||
}
|
||||
@@ -10139,3 +10139,17 @@ Mudanças de datas/fontes não substituem conclusões ou valores. A correspondê
|
||||
QA:70testes frontend afectados distintos, incluindo nove novos de domínio/UI; sucesso, falta de fonte, divergência de titular/datas, período anterior curto/não contíguo, reconferência independente, preservação da distribuição, mês único e histórico. API nativa POST/PUT/GET:quatro cenários sintéticos,46asserções, rollback em investmoz_demography_test:55441, identidade PDO conferida. Cálculo→gravação→recuperação→relatório financeiro do mesmo dossier demonstrados. Tipos, lint focado, Pint e compilação aprovados. Edge com componentes reais compilados:112000MZN nos meses relativos correctos, concentração emJaneiro2026, total indeterminado sem fonte, PEC preservado quando PPC muda e193000MZN apenas após reconferência. Corrigido aviso contraditório de ano civil no caso documental incompleto; regressão acrescentada. Provas:outputs/irpc-noncivil-20261114/. Provas aceites não afectadas reutilizadas.
|
||||
|
||||
Estado:implementado e verificado em QA, ainda sem instalação. Homologação mantém078f770d. Este lote encerra apenas as estimativas PPC/PEC e a projecção deste recorte anual não civil documentado; não encerra L6/L8, a liquidação final não civil, o simulador ou o portal. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos preservados na matriz. Sem migrações, permissões, rotas, importações, pagamentos, declarações submetidas ou novas deduções. Instalação agrupada depende de autorização concreta, executor Git, backup/restauro fresco e reversão de release preservando edições posteriores.
|
||||
|
||||
## 14/11/2026 — Ofertas, Fase 1: preparação privada em QA
|
||||
|
||||
Autorização condicionada expressa da Tempo nesta sessão: exclusivamente desenvolvimento da Fase 1. Oportunidades é o cadastro principal das novas Ofertas; negócios e restantes percursos mantêm os seus identificadores, contratos e dados. Sem mudanças no menu, esquema mne, dados operacionais ou instalação. As migrações existentes foram usadas apenas para preparar uma base sintética nova, offers_phase1_20261114_test:55441, preservando as outras bases QA.
|
||||
|
||||
Entrada privada em desenvolvimento: /minhas-oportunidades/ofertas. Requer autenticação, email verificado, perfis disponíveis e âmbito de Promotor já atribuído para criar/editar/submeter; não concede papéis. Criar → guardar → recuperar → editar → conferir → submeter em privado. Rascunhos incompletos são recuperáveis. A submissão exige título, descrição, categoria, contacto autorizado e preço MZN/USD ou «A negociar»; não converte moedas nem confunde preço pedido com investimento estimado. Localização usa os identificadores e a hierarquia existentes. Contactos usam o CRM e o seu controlo de MFA, sem novo cadastro ou publicação dos contactos.
|
||||
|
||||
Gravação usa OpportunityManagement, chave de criação, etag, versões, management_draft e auditoria existentes. Campos inesperados são recusados; permissões, representação e contacto são reconferidos no servidor. Conflitos não sobrescrevem a versão guardada. A submissão regista autor, instante UTC e revisão nos metadados privados, sem criar revisão editorial, notificações, aprovação ou publicação da Fase 2. A oferta submetida fica recuperável e sem edição nesta fase. A marca privada bloqueia conversão/publicação pelo modelo, workflow e divulgação existentes; o catálogo público exclui-a mesmo perante um estado incoerente introduzido por outro escritor.
|
||||
|
||||
Verificação: 14 testes novos. Bateria de regressão de Oportunidades/perfis/editorial: 35 testes e 548 asserções aprovados. Passagem final focada, após as últimas alterações, com 16 testes e 172 asserções aprovados; os grupos sobrepõem-se e não devem ser somados. Inclui criação idempotente, recuperação, conflito, contacto de outro titular/eliminado, perda de representação/permissão, preço incompleto/inválido, campos forjados, submissão confirmada, bloqueios de publicação e ausência nas consultas públicas. Duas páginas verificadas por HTML completo com entradas Vite reais. Pint, PHPStan, tipos, lint/formatação focados, build QA e revisão do delta aprovados. Recibo: outputs/offers-private-phase1-20261114/qa-result.json.
|
||||
|
||||
Limitações: conferência visual manual não efectuada. npm run check global acusa formatação preexistente em 61 ficheiros alheios ao delta; esses ficheiros não foram alterados e a falha não foi dispensada. A bateria canónica extensa foi interrompida, não contabilizada como aprovada e substituída pela verificação do cenário afectado. Uma prova antiga de divulgação exige outra base pelo seu próprio controlo de segurança; esse controlo foi preservado, sem tocar nessa base, e a compatibilidade do âmbito nativo foi testada na nova preparação sintética.
|
||||
|
||||
Saldo de RF-MN-003: Fase 1 de Ofertas implementada e verificada tecnicamente em QA, sem instalação. Não encerra o requisito integral nem Procuras. Fase 2 e seguintes permanecem por autorizar; NDA, comprovativos, fotografias, validade, moderação e publicação não são simulados nesta entrega. A autorização anterior de validador jurídico para legislação não é estendida a Ofertas. Instalação em homologação ou produção exige verificação e autorização expressa separada.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"date": "2026-11-14",
|
||||
"scope": "Ofertas - Fase 1 - Preparacao Privada",
|
||||
"base_revision": "e8408c27d9aeb17f534f62f18f80a28ba7f9aca5",
|
||||
"state": "implemented_and_technically_verified_in_qa_not_installed",
|
||||
"qa_database": "offers_phase1_20261114_test:55441",
|
||||
"new_tests": 14,
|
||||
"regression_run": {
|
||||
"tests": 35,
|
||||
"assertions": 548,
|
||||
"result": "passed"
|
||||
},
|
||||
"final_focused_run": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 16,
|
||||
"passed": 16,
|
||||
"assertions": 172,
|
||||
"duration_ms": 66152
|
||||
},
|
||||
"runs_overlap": true,
|
||||
"checks": {
|
||||
"pint": "passed",
|
||||
"phpstan": "passed",
|
||||
"typescript": "passed",
|
||||
"focused_frontend_format_lint": "passed",
|
||||
"qa_build": "passed",
|
||||
"full_html_real_page_entries": "passed",
|
||||
"git_diff_check": "passed",
|
||||
"global_npm_check": "failed: 61 pre-existing formatting files outside this delta; not changed or waived"
|
||||
},
|
||||
"limits": [
|
||||
"No manual browser visual review performed.",
|
||||
"An extensive canonical run was interrupted and is not counted as approved; the affected canonical scenario passed in the final focused run.",
|
||||
"The legacy OpportunityPromotionTest pins another QA database; its guard was preserved. Equivalent native-scope and private-offer blocking assertions passed in the new isolated fixture."
|
||||
],
|
||||
"new_migrations": [],
|
||||
"production_data_modified": false,
|
||||
"mne_code_modified": false,
|
||||
"menu_modified": false,
|
||||
"homologation_installed": false,
|
||||
"phase2_implemented": false,
|
||||
"private_entry": "/minhas-oportunidades/ofertas",
|
||||
"files": [
|
||||
"app/Http/Middleware/ProtectProfileOpportunity.php",
|
||||
"app/Models/Opportunity.php",
|
||||
"app/Services/OpportunityCanonicalContract.php",
|
||||
"app/Services/OpportunityContacts.php",
|
||||
"app/Services/OpportunityManagement.php",
|
||||
"app/Services/OpportunityProfileWorkflow.php",
|
||||
"app/Services/OpportunityPromotion.php",
|
||||
"app/Services/PortalProfileAccess.php",
|
||||
"docs/investmoz/unification/traceability-and-manual.md",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
|
||||
"routes/web.php",
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php",
|
||||
"app/Services/PrivateOfferPreparation.php",
|
||||
"resources/js/pages/offers/preparation-index.tsx",
|
||||
"resources/js/pages/offers/preparation.tsx",
|
||||
"tests/Feature/PrivateOfferPreparationTest.php"
|
||||
],
|
||||
"pending_decisions": [
|
||||
"Express authorization before Phase 2 or installation.",
|
||||
"Photo, legal/NDA, proof and expiry decisions remain for their later phases."
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Http/Middleware/ProtectProfileOpportunity.php": "30aa2a4b864a2bbfa536f134d93876f6ab2d03194854a846190f46847f2d2215",
|
||||
"app/Models/Opportunity.php": "b2fe01b408922f13ab2b0aef48c5ec4c07c7d692138093365059d8f8289d6560",
|
||||
"app/Services/OpportunityCanonicalContract.php": "c954840e726fa81feddc0dcf32bb85078c48bae2c9dd3b50df54b549ab4d18fa",
|
||||
"app/Services/OpportunityContacts.php": "a5b378bc35d71ca67153819abc87a4047e23db9e19c74ec12fc25e19cc9a4379",
|
||||
"app/Services/OpportunityManagement.php": "6cf600269c03f64555f978dc80076e9163fd07e17077aa4f3b222fab433779f2",
|
||||
"app/Services/OpportunityProfileWorkflow.php": "ce3ad0f52ba3ef8ac1845f300ab633b1050bbcd052b564d0ddc1fd3de2c6dcbb",
|
||||
"app/Services/OpportunityPromotion.php": "4a2d170988ad85e9af52746d7b2435eeba6efe7f815b9b4cb152c7cd537d06d0",
|
||||
"app/Services/PortalProfileAccess.php": "6388a263bfd4ef32f6ae3ce13b0ac099a7258412a1923358210a8e07394be9bc",
|
||||
"docs/investmoz/unification/traceability-and-manual.md": "4c86ea3393965b447b47c5acf51ec30d21274584d3b7b52256a6000d81dc4835",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "3c07628a9f8157fc11b6f81169669e9ea397e83b4db3335240cab6c67abee81e",
|
||||
"routes/web.php": "5fdf1a7b6f49b56028a0d17b8ae8befcda9617f485b4d593e2daf60c2166f13c",
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php": "b94e133f3ab0684c8360969a38c77208fb57f23dd9e0aebd5224d4a1fcab932d",
|
||||
"app/Services/PrivateOfferPreparation.php": "abb728e904e22b7c830efa07502507a183159051d738418ae9372fe43ca675b9",
|
||||
"resources/js/pages/offers/preparation-index.tsx": "5d092e2179f021c5d10997e51dbbd8181ca285a8cde743484131580f4616d3cb",
|
||||
"resources/js/pages/offers/preparation.tsx": "58cbc6379c8526bfa68506634ac9b34d1fa95193c71efa178da5d937e52ff016",
|
||||
"tests/Feature/PrivateOfferPreparationTest.php": "49756c500ae102460b7b8678725720a1e346d7d738273aea7cce633145e8ef0d"
|
||||
},
|
||||
"matrix_preservation": {
|
||||
"rows_changed": 1,
|
||||
"id": "RF-MN-003",
|
||||
"other_rows_unchanged": true,
|
||||
"columns_preserved": 49
|
||||
},
|
||||
"html_build_portability_run": {
|
||||
"tests": 1,
|
||||
"assertions": 10,
|
||||
"result": "passed",
|
||||
"directory_env": "EDITORIAL_QA_BUILD_DIRECTORY=build-qa-opportunities",
|
||||
"default_ci_directory": "build"
|
||||
},
|
||||
"commands": [
|
||||
"php vendor/bin/phpunit tests/Feature/PrivateOfferPreparationTest.php tests/Feature/OpportunityManagementTest.php tests/Feature/PortalProfileAccessTest.php tests/Feature/PortalEditorialWorkflowTest.php",
|
||||
"php vendor/bin/phpunit --filter PrivateOfferPreparationTest|test_phase_one_requires_a_new_legal_decision_for_each_content_and_attachment_version|test_approval_restricted_and_public_labels_follow_actual_catalog_policy tests/Feature/PrivateOfferPreparationTest.php tests/Feature/OpportunityCanonicalWorkflowTest.php tests/Feature/OpportunityPublicationScopeTest.php",
|
||||
"php vendor/bin/phpunit --filter test_full_html_uses_real_compiled_private_page_entries tests/Feature/PrivateOfferPreparationTest.php",
|
||||
"php vendor/bin/pint --dirty",
|
||||
"php vendor/bin/phpstan analyse --no-progress --memory-limit=1G",
|
||||
"npm.cmd run types:check",
|
||||
"npx.cmd vp check resources/js/pages/offers/preparation.tsx resources/js/pages/offers/preparation-index.tsx",
|
||||
"INVESTMOZ_QA_BUILD=1 npm.cmd run build",
|
||||
"npm.cmd run check",
|
||||
"git diff --check"
|
||||
],
|
||||
"test_environment": {
|
||||
"APP_ENV": "testing",
|
||||
"DB_HOST": "127.0.0.1",
|
||||
"DB_PORT": "55441",
|
||||
"DB_DATABASE": "offers_phase1_20261114_test",
|
||||
"DB_USERNAME": "demography_qa",
|
||||
"synthetic_app_key": true,
|
||||
"mail": "array",
|
||||
"queue": "sync"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
|
||||
export default function OfferPreparations({
|
||||
records,
|
||||
canCreate,
|
||||
}: {
|
||||
records: {
|
||||
data: {
|
||||
id: number;
|
||||
title: string | null;
|
||||
reference: string;
|
||||
state: string;
|
||||
}[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
canCreate: boolean;
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl space-y-6 p-4 md:p-8">
|
||||
<Head title="As minhas ofertas" />
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
As minhas ofertas
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Prepare e guarde as suas ofertas num espaço privado.
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Link
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2"
|
||||
href="/minhas-oportunidades/ofertas/criar"
|
||||
>
|
||||
Preparar oferta
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
{!records.data.length && (
|
||||
<p>Ainda não existem ofertas neste âmbito.</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{records.data.map((record) => (
|
||||
<li key={record.id} className="rounded-lg border p-4">
|
||||
<Link
|
||||
className="font-medium underline"
|
||||
href={`/minhas-oportunidades/ofertas/${record.id}`}
|
||||
>
|
||||
{record.title || 'Oferta por completar'}
|
||||
</Link>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{record.reference} ·{' '}
|
||||
{record.state === 'submitted'
|
||||
? 'Submissão privada registada'
|
||||
: 'Rascunho privado'}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<nav aria-label="Páginas das ofertas" className="flex gap-4">
|
||||
{records.prev_page_url && (
|
||||
<Link href={records.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{records.next_page_url && (
|
||||
<Link href={records.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
TerritoryFields,
|
||||
type TerritoryOption,
|
||||
} from '@/components/investment-territory';
|
||||
import OpportunityContactForm from '@/components/opportunity-contact-form';
|
||||
import { modalityLabels } from '@/components/opportunity-search-filters';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
|
||||
type Offer = {
|
||||
price_mode?: string | null;
|
||||
amount?: string | null;
|
||||
currency?: string | null;
|
||||
contact_id?: string | null;
|
||||
};
|
||||
type Context = {
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
type: string;
|
||||
};
|
||||
type RecordData = {
|
||||
id: number;
|
||||
reference: string;
|
||||
etag: string;
|
||||
state: string;
|
||||
canEdit: boolean;
|
||||
organization_id: string | null;
|
||||
territory: TerritoryOption[];
|
||||
values: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
opportunity_category: string;
|
||||
private_offer?: Offer;
|
||||
};
|
||||
};
|
||||
type Contact = { id: string; full_name: string };
|
||||
const control = 'w-full rounded-md border bg-background p-2 text-foreground';
|
||||
const button =
|
||||
'rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50';
|
||||
const emptyPath: TerritoryOption[] = [];
|
||||
|
||||
export default function OfferPreparation({
|
||||
record,
|
||||
contexts,
|
||||
contacts,
|
||||
contactInvestors,
|
||||
}: {
|
||||
record: RecordData | null;
|
||||
contexts: Context[];
|
||||
contacts: Contact[];
|
||||
contactInvestors: { id: string; legal_name: string }[];
|
||||
}) {
|
||||
const [saved, setSaved] = useState(record);
|
||||
const [creationKey] = useState(createOperationKey);
|
||||
const [context, setContext] = useState<Context>(
|
||||
record
|
||||
? {
|
||||
organization_id: record.organization_id,
|
||||
organization_name: null,
|
||||
type: record.values.opportunity_category,
|
||||
}
|
||||
: contexts[0],
|
||||
);
|
||||
const [title, setTitle] = useState(record?.values.title ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
record?.values.description ?? '',
|
||||
);
|
||||
const [offer, setOffer] = useState<Offer>(
|
||||
record?.values.private_offer ?? {
|
||||
price_mode: null,
|
||||
amount: null,
|
||||
currency: null,
|
||||
contact_id: null,
|
||||
},
|
||||
);
|
||||
const [territory, setTerritory] = useState(record?.territory ?? emptyPath);
|
||||
const [contactChoices, setContactChoices] = useState(contacts);
|
||||
const [dirty, setDirty] = useState(!record);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [message, setMessage] = useState('');
|
||||
const readOnly = saved !== null && !saved.canEdit;
|
||||
function updateOffer(next: Offer) {
|
||||
setOffer(next);
|
||||
setDirty(true);
|
||||
setConfirmed(false);
|
||||
}
|
||||
async function execute(submit: boolean) {
|
||||
if (busy || readOnly || (submit && (!saved || dirty || !confirmed)))
|
||||
return;
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
setMessage('');
|
||||
try {
|
||||
const base = '/minhas-oportunidades/ofertas';
|
||||
const result = await profileJson(
|
||||
submit
|
||||
? `${base}/${saved!.id}/submeter`
|
||||
: saved
|
||||
? `${base}/${saved.id}/guardar`
|
||||
: `${base}/rascunhos`,
|
||||
submit
|
||||
? { etag: saved!.etag, confirm: confirmed }
|
||||
: {
|
||||
...(saved
|
||||
? { etag: saved.etag }
|
||||
: { creation_key: creationKey }),
|
||||
title,
|
||||
description,
|
||||
opportunity_category: context.type,
|
||||
organization_id: context.organization_id,
|
||||
private_offer: offer,
|
||||
province_id: territory[0]?.id ?? null,
|
||||
district_id: territory[1]?.id ?? null,
|
||||
admin_post_id: territory[2]?.id ?? null,
|
||||
locality_id: territory[3]?.id ?? null,
|
||||
reason: 'Preparação privada da oferta pelo promotor autorizado.',
|
||||
},
|
||||
);
|
||||
setSaved(result.record);
|
||||
setDirty(false);
|
||||
setConfirmed(false);
|
||||
setMessage(
|
||||
submit
|
||||
? 'Submissão privada registada. A oferta não está publicada.'
|
||||
: 'Oferta guardada. Pode retomá-la mais tarde.',
|
||||
);
|
||||
if (!saved) router.visit(`${base}/${result.record.id}`);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível concluir. Os campos preenchidos foram preservados.',
|
||||
);
|
||||
if (error instanceof ProfileValidationError)
|
||||
setErrors(error.fields);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-4xl space-y-6 p-4 md:p-8">
|
||||
<Head title="Preparar oferta" />
|
||||
<Link
|
||||
href="/minhas-oportunidades/ofertas"
|
||||
className="text-sm underline"
|
||||
>
|
||||
As minhas ofertas
|
||||
</Link>
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Preparar oferta</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{saved?.state === 'submitted'
|
||||
? 'Submissão privada registada. A oferta permanece sem publicação.'
|
||||
: 'Complete os dados ao seu ritmo. A gravação e a submissão mantêm a oferta privada.'}
|
||||
</p>
|
||||
{saved && <p className="mt-2 text-sm">{saved.reference}</p>}
|
||||
</header>
|
||||
<div role="status" aria-live="polite">
|
||||
{message}
|
||||
</div>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-destructive rounded-md border p-3"
|
||||
>
|
||||
<p>Confira os seguintes campos:</p>
|
||||
<ul>
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<li key={key}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void execute(false);
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<fieldset
|
||||
disabled={busy || readOnly}
|
||||
className="space-y-4 rounded-lg border p-4 md:p-6"
|
||||
>
|
||||
<legend className="px-2 font-semibold">
|
||||
1. Identificação
|
||||
</legend>
|
||||
<label className="block">
|
||||
Categoria e promotor
|
||||
<select
|
||||
className={control}
|
||||
value={`${context.organization_id ?? ''}|${context.type}`}
|
||||
onChange={(event) => {
|
||||
const next = contexts.find(
|
||||
(item) =>
|
||||
`${item.organization_id ?? ''}|${item.type}` ===
|
||||
event.target.value,
|
||||
);
|
||||
if (next) {
|
||||
setContext(next);
|
||||
setDirty(true);
|
||||
setConfirmed(false);
|
||||
if (
|
||||
next.organization_id !==
|
||||
context.organization_id
|
||||
)
|
||||
updateOffer({
|
||||
...offer,
|
||||
contact_id: null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option
|
||||
value={`${context.organization_id ?? ''}|${context.type}`}
|
||||
>
|
||||
{modalityLabels[context.type]}
|
||||
{context.organization_name
|
||||
? ` — ${context.organization_name}`
|
||||
: ''}
|
||||
</option>
|
||||
{contexts
|
||||
.filter(
|
||||
(item) =>
|
||||
(!saved ||
|
||||
item.organization_id ===
|
||||
saved.organization_id) &&
|
||||
!(
|
||||
item.type === context.type &&
|
||||
item.organization_id ===
|
||||
context.organization_id
|
||||
),
|
||||
)
|
||||
.map((item) => (
|
||||
<option
|
||||
key={`${item.organization_id}|${item.type}`}
|
||||
value={`${item.organization_id ?? ''}|${item.type}`}
|
||||
>
|
||||
{modalityLabels[item.type]}
|
||||
{item.organization_name
|
||||
? ` — ${item.organization_name}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Título
|
||||
<input
|
||||
className={control}
|
||||
maxLength={150}
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value);
|
||||
setDirty(true);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Descrição
|
||||
<textarea
|
||||
className={control}
|
||||
rows={5}
|
||||
maxLength={50000}
|
||||
value={description}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value);
|
||||
setDirty(true);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
disabled={busy || readOnly}
|
||||
className="space-y-4 rounded-lg border p-4 md:p-6"
|
||||
>
|
||||
<legend className="px-2 font-semibold">
|
||||
2. Preço e localização
|
||||
</legend>
|
||||
<label className="block">
|
||||
Preço
|
||||
<select
|
||||
className={control}
|
||||
value={offer.price_mode ?? ''}
|
||||
onChange={(event) =>
|
||||
updateOffer({
|
||||
...offer,
|
||||
price_mode: event.target.value || null,
|
||||
amount: null,
|
||||
currency:
|
||||
event.target.value === 'amount'
|
||||
? 'MZN'
|
||||
: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Completar mais tarde</option>
|
||||
<option value="amount">Indicar montante</option>
|
||||
<option value="negotiable">A negociar</option>
|
||||
</select>
|
||||
</label>
|
||||
{offer.price_mode === 'amount' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
Montante
|
||||
<input
|
||||
className={control}
|
||||
inputMode="decimal"
|
||||
placeholder="Ex.: 125000.00"
|
||||
maxLength={19}
|
||||
value={offer.amount ?? ''}
|
||||
onChange={(event) =>
|
||||
updateOffer({
|
||||
...offer,
|
||||
amount: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Moeda
|
||||
<select
|
||||
className={control}
|
||||
value={offer.currency ?? ''}
|
||||
onChange={(event) =>
|
||||
updateOffer({
|
||||
...offer,
|
||||
currency: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione a moeda</option>
|
||||
<option>MZN</option>
|
||||
<option>USD</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<TerritoryFields
|
||||
initial={record?.territory ?? emptyPath}
|
||||
endpoint="/minhas-oportunidades/territory-options"
|
||||
disabled={busy || readOnly}
|
||||
onChange={(path) => {
|
||||
setTerritory(path);
|
||||
setDirty(true);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
disabled={busy || readOnly}
|
||||
className="space-y-4 rounded-lg border p-4 md:p-6"
|
||||
>
|
||||
<legend className="px-2 font-semibold">
|
||||
3. Contacto privado
|
||||
</legend>
|
||||
<label className="block">
|
||||
Contacto do promotor
|
||||
<select
|
||||
className={control}
|
||||
value={offer.contact_id ?? ''}
|
||||
onChange={(event) =>
|
||||
updateOffer({
|
||||
...offer,
|
||||
contact_id: event.target.value || null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione um contacto autorizado
|
||||
</option>
|
||||
{contactChoices.map((contact) => (
|
||||
<option key={contact.id} value={contact.id}>
|
||||
{contact.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
O contacto não é divulgado publicamente.
|
||||
</p>
|
||||
</fieldset>
|
||||
{!readOnly && (
|
||||
<button className={button} disabled={busy} type="submit">
|
||||
{busy ? 'A guardar…' : 'Guardar oferta'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{saved?.canEdit && (
|
||||
<details className="rounded-lg border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Acrescentar um contacto
|
||||
</summary>
|
||||
<p className="my-3 text-sm">
|
||||
Utilize o cadastro existente. A gravação do contacto
|
||||
exige MFA recente.
|
||||
</p>
|
||||
<OpportunityContactForm
|
||||
id={saved.id}
|
||||
etag={saved.etag}
|
||||
organization={saved.organization_id}
|
||||
investors={contactInvestors}
|
||||
disabled={busy}
|
||||
onSaved={(contact) => {
|
||||
setContactChoices((previous) => [
|
||||
...previous.filter(
|
||||
(item) => item.id !== contact.id,
|
||||
),
|
||||
contact,
|
||||
]);
|
||||
updateOffer({ ...offer, contact_id: contact.id });
|
||||
}}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
{!saved && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Guarde primeiro para poder acrescentar um contacto ao
|
||||
cadastro existente.
|
||||
</p>
|
||||
)}
|
||||
{saved?.canEdit && (
|
||||
<section className="space-y-3 rounded-lg border p-4 md:p-6">
|
||||
<h2 className="font-semibold">4. Conferir e submeter</h2>
|
||||
<p className="text-sm">
|
||||
A submissão regista a versão para validação futura. Não
|
||||
publica a oferta.
|
||||
</p>
|
||||
{dirty && (
|
||||
<p className="text-sm">
|
||||
Guarde as alterações antes de submeter.
|
||||
</p>
|
||||
)}
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
disabled={busy || dirty}
|
||||
onChange={(event) =>
|
||||
setConfirmed(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Conferi os dados guardados e confirmo a submissão
|
||||
privada.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy || dirty || !confirmed}
|
||||
onClick={() => void execute(true)}
|
||||
>
|
||||
Submeter em privado
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+22
-10
@@ -22,6 +22,7 @@ use App\Http\Controllers\EditorialArticleController;
|
||||
use App\Http\Controllers\EducationController;
|
||||
use App\Http\Controllers\EducationDeclarationDraftController;
|
||||
use App\Http\Controllers\EducationInvestmentController;
|
||||
use App\Http\Controllers\GlobalSearchController;
|
||||
use App\Http\Controllers\InvestmentCoordinatesController;
|
||||
use App\Http\Controllers\InvestmentDiscoveryController;
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
@@ -42,6 +43,7 @@ use App\Http\Controllers\PortalManagementController;
|
||||
use App\Http\Controllers\PortalProfileController;
|
||||
use App\Http\Controllers\PortalProfileInstallationController;
|
||||
use App\Http\Controllers\PortalServicesController;
|
||||
use App\Http\Controllers\PrivateOfferPreparationController;
|
||||
use App\Http\Controllers\ProactiveSupplyController;
|
||||
use App\Http\Controllers\ProductExperimentController;
|
||||
use App\Http\Controllers\ProductUsageMetricsController;
|
||||
@@ -50,6 +52,7 @@ use App\Http\Controllers\PublicGeographyController;
|
||||
use App\Http\Controllers\PublicPortalController;
|
||||
use App\Http\Controllers\PublicSitemapController;
|
||||
use App\Http\Controllers\SectorReconciliationController;
|
||||
use App\Http\Controllers\TechnicalSupportController;
|
||||
use App\Http\Controllers\TerritoryGeometryImportController;
|
||||
use App\Http\Middleware\AiEnabled;
|
||||
use App\Http\Middleware\EducationEnabled;
|
||||
@@ -139,8 +142,8 @@ Route::middleware(['auth', 'verified', EnsureDocumentManagement::class])->group(
|
||||
Route::get('/artigos/{id}', [EditorialArticleController::class, 'show'])->whereNumber('id')->name('editorial.show');
|
||||
Route::get('/carteira', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
|
||||
Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', \App\Http\Middleware\VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
|
||||
Route::get('/pesquisa', \App\Http\Controllers\GlobalSearchController::class)->name('portal.search');
|
||||
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
|
||||
Route::get('/pesquisa', GlobalSearchController::class)->name('portal.search');
|
||||
Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities');
|
||||
Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity');
|
||||
Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index');
|
||||
@@ -196,8 +199,8 @@ Route::get('/api/v1/investment/territory-geometries', [InvestmentTerritoryContro
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/management/profile-install', [PortalProfileInstallationController::class, 'preview']);
|
||||
Route::post('/management/profile-install', [PortalProfileInstallationController::class, 'apply']);
|
||||
Route::get('/management/profile-install/technical-support',[PortalProfileInstallationController::class,'supportPreview']);
|
||||
Route::post('/management/profile-install/technical-support',[PortalProfileInstallationController::class,'applySupport']);
|
||||
Route::get('/management/profile-install/technical-support', [PortalProfileInstallationController::class, 'supportPreview']);
|
||||
Route::post('/management/profile-install/technical-support', [PortalProfileInstallationController::class, 'applySupport']);
|
||||
Route::post('/management/profile-install/bootstrap', [PortalProfileInstallationController::class, 'bootstrap']);
|
||||
Route::get('/management/profile-install/bootstrap/{id}', [PortalProfileInstallationController::class, 'bootstrapPreview'])->whereUuid('id');
|
||||
Route::post('/management/profile-install/bootstrap/{id}/confirm', [PortalProfileInstallationController::class, 'confirmBootstrap'])->whereUuid('id');
|
||||
@@ -206,10 +209,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class])->group(function () {
|
||||
Route::get('/suporte-tecnico/incidentes',[\App\Http\Controllers\TechnicalSupportController::class,'index']);
|
||||
Route::get('/suporte-tecnico/incidentes/{reference}',[\App\Http\Controllers\TechnicalSupportController::class,'show'])->whereUuid('reference');
|
||||
Route::post('/suporte-tecnico/incidentes/{reference}',[\App\Http\Controllers\TechnicalSupportController::class,'decide'])->whereUuid('reference');
|
||||
Route::post('/suporte-tecnico/incidentes/{reference}/consultado',[\App\Http\Controllers\TechnicalSupportController::class,'consulted'])->whereUuid('reference');
|
||||
Route::get('/suporte-tecnico/incidentes', [TechnicalSupportController::class, 'index']);
|
||||
Route::get('/suporte-tecnico/incidentes/{reference}', [TechnicalSupportController::class, 'show'])->whereUuid('reference');
|
||||
Route::post('/suporte-tecnico/incidentes/{reference}', [TechnicalSupportController::class, 'decide'])->whereUuid('reference');
|
||||
Route::post('/suporte-tecnico/incidentes/{reference}/consultado', [TechnicalSupportController::class, 'consulted'])->whereUuid('reference');
|
||||
$profiles = PortalProfileController::class;
|
||||
Route::get('/perfil/promotor', [$profiles, 'requests']);
|
||||
Route::post('/perfil/promotor', [$profiles, 'requestProfile']);
|
||||
@@ -228,6 +231,15 @@ Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class]
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
|
||||
Route::prefix('/minhas-oportunidades/ofertas')->middleware([PrivateInvestmentResponse::class, 'throttle:60,1'])->group(function () {
|
||||
$offers = PrivateOfferPreparationController::class;
|
||||
Route::get('/', [$offers, 'index']);
|
||||
Route::get('/criar', [$offers, 'edit']);
|
||||
Route::post('/rascunhos', [$offers, 'save']);
|
||||
Route::get('/{id}', [$offers, 'edit'])->whereNumber('id');
|
||||
Route::post('/{id}/guardar', [$offers, 'save'])->whereNumber('id');
|
||||
Route::post('/{id}/submeter', [$offers, 'submit'])->whereNumber('id');
|
||||
});
|
||||
Route::get('/minhas-oportunidades/oferta-interna', [ProactiveSupplyController::class, 'index'])->middleware('throttle:60,1');
|
||||
Route::get('/minhas-oportunidades/oferta-interna/revisao', [ProactiveSupplyController::class, 'review'])->middleware(['throttle:60,1', PrivateInvestmentResponse::class]);
|
||||
$profiles = PortalProfileController::class;
|
||||
@@ -308,10 +320,10 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
|
||||
Route::put('drafts/{kind}/{id}', [EducationDeclarationDraftController::class, 'save'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id');
|
||||
Route::post('drafts/{kind}/{id}/submit', [EducationDeclarationDraftController::class, 'submit'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id');
|
||||
Route::get('/', [EducationController::class, 'index'])->name('index');
|
||||
Route::get('{kind}/{id}', [EducationController::class, 'declaration'])->whereIn('kind',['demands','capacities'])->whereUuid('id')->name('declaration');
|
||||
Route::get('{kind}/{id}', [EducationController::class, 'declaration'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id')->name('declaration');
|
||||
Route::post('catalog/{kind}', [EducationController::class, 'catalog'])->whereIn('kind', ['skills', 'occupations', 'providers', 'courses'])->name('catalog');
|
||||
Route::post('{kind}', [EducationController::class, 'store'])->whereIn('kind', ['demands', 'capacities'])->name('store');
|
||||
Route::match(['GET', 'POST'], '{kind}/{id}/review-preparation', [EducationController::class, 'reviewPreparation'])->whereIn('kind', ['demands','capacities'])->whereUuid('id');
|
||||
Route::match(['GET', 'POST'], '{kind}/{id}/review-preparation', [EducationController::class, 'reviewPreparation'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id');
|
||||
Route::post('{kind}/{id}/review', [EducationController::class, 'review'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id')->name('review');
|
||||
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
|
||||
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityPromotion;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PrivateOfferPreparationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE = '/minhas-oportunidades/ofertas';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
config(['portal.profile_access_enabled' => true]);
|
||||
}
|
||||
|
||||
private function promoter(string $category = 'business_sale', ?string $organization = null): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $user->id, 'role' => 'promoter', 'organization_id' => $organization,
|
||||
'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture',
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function payload(): array
|
||||
{
|
||||
return ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale',
|
||||
'title' => 'Empresa sintética QA', 'description' => 'Descrição sintética privada para preparação de uma oferta.',
|
||||
'private_offer' => ['price_mode' => 'amount', 'amount' => '123456.78', 'currency' => 'MZN', 'contact_id' => null],
|
||||
'reason' => 'Preparação sintética da oferta na base isolada QA.'];
|
||||
}
|
||||
|
||||
private function contact(User $owner): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('contacts')->insert(['id' => $id, 'full_name' => 'Contacto sintético QA', 'email' => 'offer-'.Str::uuid().'@example.test',
|
||||
'phone' => '+258840000000', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$payload = $this->payload();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
|
||||
$this->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->assertJsonPath('record.id', $saved['id']);
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'owner_user_id' => $actor->id, 'status' => 'draft', 'published_at' => null, 'estimated_investment_amount' => null]);
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('record.values.private_offer.amount', '123456.78');
|
||||
$this->get(self::BASE.'/'.$saved['id'])->assertInertia(fn (Assert $page) => $page->component('offers/preparation')->where('record.canEdit', true));
|
||||
$updated = $this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'title' => 'Título revisto QA',
|
||||
'private_offer' => ['price_mode' => 'negotiable', 'amount' => null, 'currency' => null], 'reason' => $payload['reason']])->assertOk()->json('record');
|
||||
$this->assertSame($saved['id'], $updated['id']);
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertJsonPath('record.values.title', 'Título revisto QA')->assertJsonPath('record.values.private_offer.price_mode', 'negotiable');
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'title' => 'Edição concorrente', 'reason' => $payload['reason']])->assertConflict();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'title' => 'Título revisto QA']);
|
||||
}
|
||||
|
||||
public function test_incomplete_draft_is_saved_but_submission_requires_common_fields_and_authorized_contact(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(),
|
||||
'opportunity_category' => 'business_sale', 'private_offer' => ['price_mode' => null], 'reason' => $this->payload()['reason']])->assertCreated()->json('record');
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors(['title', 'description', 'private_offer.contact_id']);
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'status' => 'draft', 'published_at' => null]);
|
||||
}
|
||||
|
||||
public function test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$payload = $this->payload();
|
||||
$payload['private_offer']['contact_id'] = $this->contact($actor);
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
|
||||
$url = self::BASE.'/'.$saved['id'].'/submeter';
|
||||
$this->postJson($url, ['etag' => $saved['etag'], 'confirm' => false])->assertUnprocessable();
|
||||
$this->postJson($url, ['etag' => str_repeat('0', 64), 'confirm' => true])->assertConflict();
|
||||
$submitted = $this->postJson($url, ['etag' => $saved['etag'], 'confirm' => true])->assertOk()->assertJsonPath('record.state', 'submitted')->assertJsonPath('record.canEdit', false)->json('record');
|
||||
$this->postJson($url, ['etag' => $submitted['etag'], 'confirm' => true])->assertOk();
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertJsonPath('record.state', 'submitted');
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $submitted['etag'], 'reason' => $payload['reason']])->assertForbidden();
|
||||
$record = Opportunity::findOrFail($saved['id']);
|
||||
$this->assertSame([], app(OpportunityProfileWorkflow::class)->actions($actor, $record));
|
||||
$this->assertFalse(app(OpportunityPromotion::class)->allowed($actor, $record));
|
||||
$this->assertNull($record->published_at);
|
||||
$this->assertSame('draft', $record->status);
|
||||
$this->assertSame(1, DB::table('audit_events')->where('action', 'private_offer_submitted')->count());
|
||||
$this->assertDatabaseCount('portal_editorial_editions', 0);
|
||||
$this->assertDatabaseCount('portal_editorial_notifications', 0);
|
||||
}
|
||||
|
||||
public function test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate(): void
|
||||
{
|
||||
$this->get(self::BASE.'/criar')->assertRedirect('/login');
|
||||
$ungranted = User::factory()->create();
|
||||
$this->actingAs($ungranted)->postJson(self::BASE.'/rascunhos', $this->payload())->assertForbidden();
|
||||
$this->actingAs(User::factory()->unverified()->create())->get(self::BASE.'/criar')->assertRedirect('/email/verify');
|
||||
$actor = $this->promoter();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
|
||||
$other = $this->promoter();
|
||||
$this->actingAs($other)->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag']])->assertForbidden();
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertForbidden();
|
||||
$this->get(self::BASE)->assertInertia(fn (Assert $page) => $page->has('records.data', 0));
|
||||
DB::table('portal_role_assignments')->where('user_id', $actor->id)->update(['starts_at' => now()->subHour(), 'expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($actor)->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_contact_scope_and_malicious_fields_roll_back_without_parallel_records(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$payload = $this->payload();
|
||||
$payload['private_offer']['contact_id'] = $this->contact($this->promoter());
|
||||
$this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertUnprocessable()->assertJsonValidationErrors('private_offer.contact_id');
|
||||
$this->assertDatabaseCount('opportunities', 0);
|
||||
$payload = $this->payload();
|
||||
foreach (['status' => 'published', 'owner_user_id' => $actor->id, 'workflow_context' => ['publication' => ['visibility' => 'public']]] as $key => $value) {
|
||||
$this->postJson(self::BASE.'/rascunhos', [...$payload, $key => $value])->assertUnprocessable();
|
||||
}
|
||||
$this->postJson(self::BASE.'/rascunhos', [...$payload, 'opportunity_category' => 'real_estate'])->assertForbidden();
|
||||
$this->assertDatabaseCount('opportunities', 0);
|
||||
}
|
||||
|
||||
public function test_price_contract_rejects_conversion_modes_precision_and_negotiable_amount(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
foreach ([['price_mode' => 'amount', 'amount' => '10.123', 'currency' => 'MZN'], ['price_mode' => 'amount', 'amount' => '100', 'currency' => 'EUR'],
|
||||
['price_mode' => 'negotiable', 'amount' => '100', 'currency' => 'USD'], ['price_mode' => 'amount', 'amount' => '-1', 'currency' => 'USD'], ['price_mode' => 'amount', 'amount' => 100.01, 'currency' => 'USD']] as $price) {
|
||||
$this->actingAs($actor)->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'private_offer' => $price])->assertUnprocessable();
|
||||
}
|
||||
$this->assertDatabaseCount('opportunities', 0);
|
||||
}
|
||||
|
||||
public function test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
|
||||
$id = $saved['id'];
|
||||
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $saved['etag'], 'title' => 'Contorno', 'reason' => $this->payload()['reason']])->assertForbidden();
|
||||
foreach (['submit', 'approve', 'publish'] as $action) {
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $saved['etag'], 'action' => $action, 'reason' => $this->payload()['reason']])->assertForbidden();
|
||||
}
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar', [])->assertForbidden();
|
||||
$this->get('/minhas-oportunidades/'.$id.'/edit')->assertRedirect(self::BASE.'/'.$id);
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$this->assertFalse(app(OpportunityPromotion::class)->allowed($actor, $record));
|
||||
$this->actingAs(User::factory()->create())->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
|
||||
$this->get('/oportunidades/'.$record->slug)->assertNotFound();
|
||||
$this->getJson('/api/v1/opportunities/search?q=Empresa')->assertOk()->assertJsonPath('total', 0);
|
||||
// Defence in depth: even inconsistent data from an external writer is excluded.
|
||||
DB::table('opportunities')->where('id', $id)->update(['status' => 'published', 'published_at' => now()->subMinute(),
|
||||
'workflow_context' => json_encode([...$record->workflow_context, 'publication' => ['visibility' => 'public']])]);
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
$this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_existing_opportunity_cannot_be_converted_through_offer_adapter(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$native = $this->actingAs($actor)->postJson('/minhas-oportunidades/drafts', $this->payload())->assertCreated()->json();
|
||||
$this->getJson(self::BASE.'/'.$native['id'])->assertNotFound();
|
||||
$this->postJson(self::BASE.'/'.$native['id'].'/guardar', ['etag' => $native['etag']])->assertNotFound();
|
||||
$this->assertFalse(Opportunity::findOrFail($native['id'])->isPrivateOfferPreparation());
|
||||
$publisher = User::factory()->create();
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $publisher->id, 'role' => 'publisher', 'organization_id' => null,
|
||||
'opportunity_types' => ['business_sale'], 'source' => 'synthetic-test-fixture',
|
||||
]);
|
||||
$this->assertTrue(app(OpportunityPromotion::class)->allowed($publisher, Opportunity::findOrFail($native['id'])));
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
}
|
||||
|
||||
public function test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
|
||||
$data = ['etag' => $saved['etag'], 'full_name' => 'Contacto de teste QA', 'email' => 'private-offer@example.test', 'phone' => '+258840000001'];
|
||||
$url = '/minhas-oportunidades/'.$saved['id'].'/contacts';
|
||||
$this->postJson($url, $data)->assertForbidden();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time()]);
|
||||
$contact = $this->postJson($url, $data)->assertOk()->json('contact');
|
||||
$this->postJson($url, $data)->assertOk()->assertJsonPath('contact.id', $contact['id']);
|
||||
$payload = $this->payload();
|
||||
$payload['private_offer']['contact_id'] = $contact['id'];
|
||||
$saved = $this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'private_offer' => $payload['private_offer'], 'reason' => $payload['reason']])->assertOk()->json('record');
|
||||
DB::table('contacts')->where('id', $contact['id'])->update(['deleted_at' => now()]);
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors('private_offer.contact_id');
|
||||
$this->assertSame('draft', Opportunity::findOrFail($saved['id'])->workflow_context['private_offer_preparation']['state']);
|
||||
}
|
||||
|
||||
public function test_model_cannot_publish_or_remove_private_phase_marker(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
|
||||
foreach ([['status' => 'published', 'published_at' => now()], ['workflow_context' => []]] as $change) {
|
||||
try {
|
||||
Opportunity::findOrFail($saved['id'])->forceFill($change)->save();
|
||||
$this->fail('A preparação privada foi exposta ou convertida.');
|
||||
} catch (HttpException $exception) {
|
||||
$this->assertSame(403, $exception->getStatusCode());
|
||||
}
|
||||
}
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'status' => 'draft', 'published_at' => null]);
|
||||
}
|
||||
|
||||
public function test_location_uses_existing_hierarchy_and_invalid_selection_never_saves(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$ids = [];
|
||||
foreach (['country', 'province', 'district'] as $index => $type) {
|
||||
$ids[$index] = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $ids[$index], 'code' => $ids[$index], 'name' => 'QA '.$type,
|
||||
'location_type' => $type, 'parent_id' => $index ? $ids[$index - 1] : null, 'data_classification' => 'internal']);
|
||||
}
|
||||
$payload = [...$this->payload(), 'province_id' => $ids[1], 'district_id' => $ids[2]];
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertJsonPath('record.territory.0.id', $ids[1])->assertJsonPath('record.territory.1.id', $ids[2]);
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'province_id' => $ids[2], 'district_id' => $ids[1], 'reason' => $payload['reason']])->assertUnprocessable();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'primary_territory_id' => $ids[2]]);
|
||||
}
|
||||
|
||||
public function test_full_html_uses_real_compiled_private_page_entries(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
|
||||
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot')));
|
||||
$this->actingAs($actor);
|
||||
foreach (['offers/preparation-index' => self::BASE, 'offers/preparation' => self::BASE.'/criar'] as $page => $url) {
|
||||
$key = 'resources/js/pages/'.$page.'.tsx';
|
||||
$this->assertArrayHasKey($key, $manifest);
|
||||
$this->get($url, ['Accept' => 'text/html'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$key]['file'], false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable(): void
|
||||
{
|
||||
$organization = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $organization, 'name' => 'Organização sintética QA', 'organization_type' => 'private']);
|
||||
$actor = $this->promoter('business_sale', $organization);
|
||||
$membership = (string) Str::uuid();
|
||||
DB::table('memberships')->insert(['id' => $membership, 'user_id' => $actor->id, 'organization_id' => $organization, 'role' => 'officer']);
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'organization_id' => $organization])->assertCreated()->json('record');
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'organization_id' => null, 'reason' => $this->payload()['reason']])->assertForbidden();
|
||||
DB::table('memberships')->where('id', $membership)->update(['deleted_at' => now()]);
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'reason' => $this->payload()['reason']])->assertForbidden();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'organization_id' => $organization]);
|
||||
}
|
||||
|
||||
public function test_incomplete_price_is_preserved_but_cannot_be_submitted_and_invalid_structures_return_validation_errors(): void
|
||||
{
|
||||
$actor = $this->promoter();
|
||||
$payload = $this->payload();
|
||||
$payload['private_offer'] = ['price_mode' => 'amount', 'amount' => null, 'currency' => null, 'contact_id' => $this->contact($actor)];
|
||||
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
|
||||
$this->getJson(self::BASE.'/'.$saved['id'])->assertJsonPath('record.values.private_offer.price_mode', 'amount');
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors(['private_offer.amount', 'private_offer.currency']);
|
||||
$this->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'private_offer' => 'invalid'])->assertUnprocessable();
|
||||
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'opportunity_category' => null, 'reason' => $payload['reason']])->assertUnprocessable();
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user