This commit is contained in:
@@ -2,13 +2,16 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Domain\Interests\Enums\InterestStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreInterestRequestRequest;
|
||||
use App\Jobs\NotifyInterestReceived;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@@ -33,8 +36,17 @@ class InterestRequestController extends Controller
|
||||
throw ValidationException::withMessages(['opportunityReference' => 'A oportunidade seleccionada já não está disponível.']);
|
||||
}
|
||||
|
||||
$business = ! empty($validated['existingBusinessId'])
|
||||
? ExistingBusiness::published()->whereKey($validated['existingBusinessId'])->first()
|
||||
: null;
|
||||
if (! empty($validated['existingBusinessId']) && ! $business) {
|
||||
throw ValidationException::withMessages(['existingBusinessId' => 'A oportunidade seleccionada já não está disponível.']);
|
||||
}
|
||||
|
||||
$interest = InterestRequest::create([
|
||||
'opportunity_id' => $opportunity?->id,
|
||||
'existing_business_id' => $business?->id,
|
||||
'modality' => $business ? $validated['modality'] : null,
|
||||
'reference' => $this->generateReference(),
|
||||
'full_name' => trim($validated['fullName']),
|
||||
'email' => strtolower(trim($validated['email'])),
|
||||
@@ -47,7 +59,7 @@ class InterestRequestController extends Controller
|
||||
'message' => trim($validated['message']),
|
||||
'privacy_consent' => (bool) $validated['privacyConsent'],
|
||||
'consented_at' => now(),
|
||||
'status' => 'received',
|
||||
'status' => InterestStatus::Received->value,
|
||||
'source_page' => Str::limit($request->headers->get('referer', ''), 255, ''),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
@@ -78,6 +90,12 @@ class InterestRequestController extends Controller
|
||||
} catch (IdempotencyConflict) {
|
||||
return response()->json(['success' => false, 'message' => 'A chave de envio já foi usada com dados diferentes.'], 409);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (QueryException $exception) {
|
||||
if ($exception->getCode() === '23505') {
|
||||
return response()->json(['success' => false, 'message' => 'Já existe uma manifestação activa para esta oportunidade e modalidade.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Erro ao registar manifestação de interesse', [
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LegislationSourceController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$today = today()->toDateString();
|
||||
$query = InvestmentLegalSource::query()
|
||||
->where('validation_status', 'validated')
|
||||
->whereDate('valid_from', '<=', $today)
|
||||
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $today));
|
||||
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
|
||||
$query->where(fn ($query) => $query
|
||||
->whereRaw('title ILIKE ?', [$term])
|
||||
->orWhereRaw('diploma ILIKE ?', [$term])
|
||||
->orWhereRaw('article ILIKE ?', [$term])
|
||||
->orWhereRaw('competent_entity ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
$page = $query->orderBy('title')->orderBy('id')->paginate($filters['per_page'] ?? 25)->withQueryString();
|
||||
$data = collect($page->items())->map(fn (InvestmentLegalSource $source): array => [
|
||||
'id' => $source->id,
|
||||
'title' => $source->title,
|
||||
'url' => $source->url,
|
||||
'diploma' => $source->diploma,
|
||||
'article' => $source->article,
|
||||
'competent_entity' => $source->competent_entity,
|
||||
'validation_status' => $source->validation_status,
|
||||
'version_no' => (int) $source->version_no,
|
||||
'valid_from' => $source->valid_from,
|
||||
'valid_until' => $source->valid_until,
|
||||
'validator_id' => $source->validator_id === null ? null : (int) $source->validator_id,
|
||||
'validated_at' => $source->validated_at,
|
||||
])->values();
|
||||
$body = [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
],
|
||||
];
|
||||
$etag = 'W/"legal-sources-'.hash('sha256', json_encode([
|
||||
'filters' => $filters,
|
||||
'data' => $data,
|
||||
'total' => $page->total(),
|
||||
], JSON_THROW_ON_ERROR)).'"';
|
||||
|
||||
return response()->json($body)
|
||||
->header('ETag', $etag)
|
||||
->header('Cache-Control', 'public, max-age=300');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ReferenceCatalogController extends Controller
|
||||
{
|
||||
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities'];
|
||||
|
||||
public function index(Request $request, string $catalog): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
|
||||
$rules = [
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'version' => ['nullable', 'integer', 'min:1'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'parent_id' => ['nullable', 'uuid'],
|
||||
'jurisdiction_id' => ['nullable', 'uuid'],
|
||||
];
|
||||
if ($catalog === 'jurisdictions') {
|
||||
$rules['type'] = ['required', 'in:country,province,district,admin_post,locality'];
|
||||
$rules['parent_id'][] = 'prohibited_if:type,country';
|
||||
$rules['parent_id'][] = 'required_if:type,district,admin_post,locality';
|
||||
}
|
||||
if ($catalog === 'economic-activities') {
|
||||
$rules['sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
|
||||
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $catalog)->where('locale', 'pt-MZ');
|
||||
if (isset($data['version'])) {
|
||||
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
|
||||
} else {
|
||||
$versionQuery->where('status', 'published')
|
||||
->where(fn (Builder $query) => $query->whereNull('valid_from')->orWhere('valid_from', '<=', today()->toDateString()))
|
||||
->where(fn (Builder $query) => $query->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()));
|
||||
}
|
||||
$version = $versionQuery->orderByDesc('version_no')->first();
|
||||
|
||||
if (isset($data['version']) && ! $version) {
|
||||
abort(404, 'Versão publicada do catálogo não encontrada.');
|
||||
}
|
||||
|
||||
if (! $version) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'links' => ['next' => null, 'previous' => null],
|
||||
'next_page_url' => null,
|
||||
'prev_page_url' => null,
|
||||
'current_page' => 1,
|
||||
'per_page' => $data['per_page'] ?? 20,
|
||||
'total' => 0,
|
||||
'last_page' => 1,
|
||||
'meta' => ['catalog' => $catalog, 'version' => null, 'locale' => 'pt-MZ', 'status' => 'not_published',
|
||||
'current_page' => 1, 'per_page' => $data['per_page'] ?? 20, 'total' => 0, 'last_page' => 1],
|
||||
])->header('Cache-Control', 'public, max-age=60');
|
||||
}
|
||||
|
||||
$query = match ($catalog) {
|
||||
'authorities' => $this->authorities((string) $version->id, $data),
|
||||
'jurisdictions' => $this->jurisdictions($data),
|
||||
'economic-activities' => $this->economicActivities((string) $version->id, $data),
|
||||
};
|
||||
|
||||
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, $row))->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'links' => ['next' => $page->nextPageUrl(), 'previous' => $page->previousPageUrl()],
|
||||
'next_page_url' => $page->nextPageUrl(),
|
||||
'prev_page_url' => $page->previousPageUrl(),
|
||||
'current_page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
'last_page' => $page->lastPage(),
|
||||
'meta' => ['catalog' => $catalog, 'version' => (int) $version->version_no, 'locale' => $version->locale,
|
||||
'source_version' => $version->source_version, 'valid_from' => $version->valid_from, 'valid_to' => $version->valid_to,
|
||||
'current_page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
|
||||
])->header('Cache-Control', 'public, max-age=300')
|
||||
->header('ETag', 'W/"catalog-'.substr(hash('sha256', $catalog.'|'.$version->version_no.'|'.json_encode($data, JSON_THROW_ON_ERROR)), 0, 32).'"');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function authorities(string $versionId, array $data): Builder
|
||||
{
|
||||
$query = DB::table('authorities as a')->join('organizations as o', 'o.id', '=', 'a.organization_id')
|
||||
->leftJoin('geo_locations as g', 'g.id', '=', 'a.jurisdiction_id')
|
||||
->where('a.catalog_version_id', $versionId)->where('a.status', 'published')
|
||||
->where('o.data_classification', 'public')->whereNull('o.deleted_at')
|
||||
->where('a.valid_from', '<=', today()->toDateString())
|
||||
->where(fn (Builder $builder) => $builder->whereNull('a.valid_to')->orWhere('a.valid_to', '>', today()->toDateString()))
|
||||
->select(['a.id', 'a.code', 'a.authority_type', 'a.label_pt_mz as name', 'a.organization_id', 'a.jurisdiction_id',
|
||||
'g.name as jurisdiction_name', 'a.version_no']);
|
||||
if (! empty($data['jurisdiction_id'])) {
|
||||
$query->where('a.jurisdiction_id', $data['jurisdiction_id']);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['a.code', 'a.label_pt_mz', 'o.name']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function jurisdictions(array $data): Builder
|
||||
{
|
||||
$query = DB::table('geo_locations')->where('source_system', 'cartografia-workbook')
|
||||
->where('data_classification', 'public')->whereNull('deleted_at')->where('location_type', $data['type'])
|
||||
->select(['id', 'code', 'name', 'parent_id', 'location_type', 'source_code', 'version_no']);
|
||||
$parentType = ['province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']] ?? null;
|
||||
if ($data['type'] !== 'country' && ! empty($data['parent_id'])) {
|
||||
$validParent = DB::table('geo_locations')->where('id', $data['parent_id'])->where('location_type', $parentType)
|
||||
->where('source_system', 'cartografia-workbook')->where('data_classification', 'public')->whereNull('deleted_at')->exists();
|
||||
if (! $validParent) {
|
||||
throw ValidationException::withMessages(['parent_id' => 'Seleccione um território pai activo do nível anterior.']);
|
||||
}
|
||||
$query->where('parent_id', $data['parent_id']);
|
||||
} elseif (in_array($data['type'], ['district', 'admin_post', 'locality'], true)) {
|
||||
throw ValidationException::withMessages(['parent_id' => 'Seleccione primeiro o território pai.']);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['code', 'name', 'source_code']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function economicActivities(string $versionId, array $data): Builder
|
||||
{
|
||||
$query = DB::table('economic_activities')->where('catalog_version_id', $versionId)->where('status', 'published')
|
||||
->where('valid_from', '<=', today()->toDateString())
|
||||
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
|
||||
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
|
||||
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
|
||||
$query->where('parent_id', $data['parent_id']);
|
||||
} else {
|
||||
$query->whereNull('parent_id');
|
||||
}
|
||||
if (! empty($data['sector_id'])) {
|
||||
$query->where('sector_id', $data['sector_id']);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
private function search(Builder $query, string $search, array $columns): void
|
||||
{
|
||||
$term = '%'.addcslashes($search, '%_\\').'%';
|
||||
$query->where(function (Builder $nested) use ($columns, $term): void {
|
||||
foreach ($columns as $index => $column) {
|
||||
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
|
||||
$nested->{$method}($column.' ILIKE ?', [$term]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function present(string $catalog, object $row): array
|
||||
{
|
||||
return match ($catalog) {
|
||||
'jurisdictions' => ['id' => $row->id, 'value' => $row->id, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'type' => $row->location_type,
|
||||
'source_code' => $row->source_code, 'version_no' => $row->version_no],
|
||||
'economic-activities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
|
||||
'version_no' => $row->version_no],
|
||||
'authorities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'authority_type' => $row->authority_type,
|
||||
'organization_id' => $row->organization_id, 'jurisdiction_id' => $row->jurisdiction_id,
|
||||
'jurisdiction_name' => $row->jurisdiction_name, 'version_no' => $row->version_no],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSimulationRequest;
|
||||
use App\Http\Requests\UpdateSimulationScenarioRequest;
|
||||
use App\Models\QuestionnaireVersion;
|
||||
use App\Models\SimulationJob;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationController extends Controller
|
||||
{
|
||||
public function questionnaire(string $code): JsonResponse
|
||||
{
|
||||
abort_unless(preg_match('/^[a-z0-9][a-z0-9-]{0,79}$/', $code) === 1, 404);
|
||||
$version = QuestionnaireVersion::query()->where('code', $code)->where('locale', 'pt-MZ')->where('status', 'published')
|
||||
->where(fn ($query) => $query->whereNull('valid_from')->orWhereDate('valid_from', '<=', today()))
|
||||
->where(fn ($query) => $query->whereNull('valid_to')->orWhereDate('valid_to', '>', today()))
|
||||
->orderByDesc('version_no')->firstOrFail();
|
||||
$etag = '"questionnaire-'.$version->id.'-v'.$version->version_no.'"';
|
||||
|
||||
return response()->json(['data' => $version])->header('ETag', $etag)->header('Cache-Control', 'private, max-age=60');
|
||||
}
|
||||
|
||||
public function store(StoreSimulationRequest $request, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$actorId = $request->user()?->id;
|
||||
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
|
||||
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
|
||||
$validated = $request->validated();
|
||||
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
|
||||
$organizationId = $data['organization_id'] ?? null;
|
||||
$execution->authorizeOrganization($actorId, $organizationId);
|
||||
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
|
||||
$simulation = $result['simulation'];
|
||||
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
|
||||
if ($actorId === null) {
|
||||
$body['access_token'] = $token;
|
||||
}
|
||||
|
||||
return response()->json($body, $result['replayed'] ? 200 : 202)
|
||||
->header('ETag', $execution->etag($simulation))
|
||||
->header('Location', url('/api/v1/simulations/'.$simulation->id));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
|
||||
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
|
||||
}
|
||||
|
||||
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->update($request->user(), $simulation), 403);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$data = ['label' => (string) $validated['label'], 'base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0')];
|
||||
$updated = $execution->updateScenario($id, $scenarioId, $data, $request);
|
||||
|
||||
return response()->json(['data' => $updated], 202)->header('ETag', $execution->etag($updated));
|
||||
}
|
||||
|
||||
public function report(Request $request, string $id, string $reportId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($reportId), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
$report = $simulation->reports->firstWhere('id', $reportId);
|
||||
abort_if($report === null, 404);
|
||||
|
||||
return response()->json(['data' => $report]);
|
||||
}
|
||||
|
||||
public function jobs(Request $request, string $id, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
|
||||
$jobs = DB::transaction(function () use ($request, $id, $execution) {
|
||||
$execution->setContext($request->user()?->id, $execution->tokenHash($request));
|
||||
|
||||
return SimulationJob::query()->where('simulation_id', $id)
|
||||
->orderBy('created_at')->orderBy('id')->get();
|
||||
});
|
||||
|
||||
return response()->json(['data' => $jobs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSimulationFormDraftRequest;
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Services\SimulationFormDrafts;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationFormDraftController extends Controller
|
||||
{
|
||||
public function store(StoreSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$result = $drafts->create($request->validated(), $request, $key);
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$draft = $drafts->current($request);
|
||||
if (! $draft) {
|
||||
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->find($id, $request);
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->update($id, $request->validated(), $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$drafts->delete($id, $request);
|
||||
|
||||
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ class ExistingBusinessController extends Controller
|
||||
$query = app(BusinessRepository::class)->query();
|
||||
if ($management) {
|
||||
Gate::authorize('reviewQueue', ExistingBusiness::class);
|
||||
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']);
|
||||
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', [
|
||||
'submitted', 'triage', 'in_review', 'needs_information', 'approval_l1', 'approval_l2', 'approved',
|
||||
]);
|
||||
} else {
|
||||
$query->where('owner_user_id', $request->user()->id);
|
||||
}
|
||||
@@ -45,6 +47,10 @@ class ExistingBusinessController extends Controller
|
||||
app(CompatibilityShadow::class)->schedule($id);
|
||||
$props = ['sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
|
||||
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
|
||||
'canTriage' => $request->user()->can('triage', $business),
|
||||
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
|
||||
'canApproveLevelTwo' => $request->user()->can('approveLevelTwo', $business),
|
||||
'canPublish' => $request->user()->can('publish', $business),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
'objectKinds' => ExistingBusinessObjects::KINDS,
|
||||
'verificationChecks' => ExistingBusinessVerification::CHECKS,
|
||||
@@ -90,6 +96,10 @@ class ExistingBusinessController extends Controller
|
||||
/** @return array<string,mixed> */
|
||||
private function record(ExistingBusiness $business): array
|
||||
{
|
||||
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id']), 'payload' => $business->payload];
|
||||
return [...$business->only([
|
||||
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
|
||||
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
|
||||
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
|
||||
]), 'payload' => $business->payload];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ class InvestmentLegalSourceController extends Controller
|
||||
$source->refresh();
|
||||
$this->audit($source, $request->user()->id, 'created');
|
||||
|
||||
return response()->json($source->fresh(), 201);
|
||||
return response()->json($source->fresh(), 201)
|
||||
->header('Location', url('/api/v2/investment/admin/legal-sources/'.$source->id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ use App\Models\InterestRequest;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\InterestWorkflow;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -22,7 +26,7 @@ use Inertia\Response;
|
||||
|
||||
class PortalManagementController extends Controller
|
||||
{
|
||||
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
|
||||
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
|
||||
|
||||
public function history(): Response
|
||||
{
|
||||
@@ -50,9 +54,10 @@ class PortalManagementController extends Controller
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(self::STATES)],
|
||||
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
|
||||
]);
|
||||
$query = InterestRequest::query()->select([
|
||||
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
|
||||
$query = InterestRequest::query()->with('assignee:id,name')->select([
|
||||
'id', 'reference', 'full_name', 'email', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
|
||||
]);
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
|
||||
@@ -61,6 +66,15 @@ class PortalManagementController extends Controller
|
||||
if ($status = $filters['status'] ?? null) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if (($filters['follow_up'] ?? null) === 'overdue') {
|
||||
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
|
||||
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
|
||||
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
|
||||
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
|
||||
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
}
|
||||
|
||||
return Inertia::render('management/interests', [
|
||||
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
|
||||
@@ -70,31 +84,94 @@ class PortalManagementController extends Controller
|
||||
|
||||
public function interest(InterestRequest $interest): Response
|
||||
{
|
||||
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
|
||||
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
|
||||
->map(function ($row) {
|
||||
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
|
||||
unset($row->reason);
|
||||
|
||||
return $row;
|
||||
});
|
||||
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
|
||||
|
||||
return Inertia::render('management/interest', [
|
||||
'interest' => $interest->only([
|
||||
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
|
||||
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
|
||||
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
|
||||
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
|
||||
]),
|
||||
'history' => $history,
|
||||
'consents' => $consents,
|
||||
'assignees' => $this->interestAssignees()->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]);
|
||||
DB::transaction(function () use ($request, $interest, $validated) {
|
||||
$locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
|
||||
$changed = $locked->status !== $validated['status'];
|
||||
PortalAudit::save($locked, [
|
||||
...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id,
|
||||
], $request->user()->id);
|
||||
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
|
||||
if ($changed && $submission) {
|
||||
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]);
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', Rule::in(self::STATES)],
|
||||
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
|
||||
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
|
||||
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
|
||||
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
|
||||
]);
|
||||
$before = $interest->status;
|
||||
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
|
||||
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
|
||||
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
|
||||
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
|
||||
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
|
||||
if ($before !== $updated->status && $submission) {
|
||||
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
|
||||
}
|
||||
|
||||
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
DB::transaction(function () use ($request, $interest, $data) {
|
||||
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
|
||||
if (! $consent) {
|
||||
return;
|
||||
}
|
||||
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
|
||||
$correlationId = (string) Str::uuid();
|
||||
DB::table('business_workflow_transitions')->insert([
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
|
||||
'from_status' => $interest->status, 'to_status' => $interest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
|
||||
'correlation_id' => $correlationId,
|
||||
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
|
||||
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('outbox_events')->insertOrIgnore([
|
||||
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
|
||||
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
|
||||
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
|
||||
'correlation_id' => $correlationId, 'status' => 'pending',
|
||||
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
|
||||
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
private function interestAssignees(): Builder
|
||||
{
|
||||
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
|
||||
fn (string $designation) => config('existing-business.'.$designation, []),
|
||||
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
|
||||
)));
|
||||
|
||||
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
|
||||
}
|
||||
|
||||
public function catalog(Request $request, string $resource): Response
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Resources\PublicBusinessOpportunityResource;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Services\ExistingBusinessDrafts;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PublicBusinessOpportunityController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'sector' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
]);
|
||||
|
||||
$query = $this->publishedQuery();
|
||||
if (! empty($filters['q'])) {
|
||||
$term = trim($filters['q']);
|
||||
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
|
||||
->orWhereLike('existing_businesses.activity_code', '%'.$term.'%')
|
||||
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
|
||||
}
|
||||
if (! empty($filters['sector'])) {
|
||||
$query->where('existing_businesses.sector_id', $filters['sector']);
|
||||
}
|
||||
|
||||
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->withQueryString());
|
||||
if ($request->expectsJson()) {
|
||||
return $results->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response|JsonResponse
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
$resource = new PublicBusinessOpportunityResource($record);
|
||||
if ($request->expectsJson()) {
|
||||
return $resource->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
|
||||
}
|
||||
|
||||
public function interest(Request $request, string $slug): Response
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
|
||||
return Inertia::render('investment/business-interest', [
|
||||
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Builder<ExistingBusiness> */
|
||||
private function publishedQuery(): Builder
|
||||
{
|
||||
$publicLocations = DB::table('existing_business_locations as locations')
|
||||
->leftJoin('geo_locations as province', 'province.id', '=', 'locations.province_id')
|
||||
->leftJoin('geo_locations as district', 'district.id', '=', 'locations.district_id')
|
||||
->leftJoin('geo_locations as locality', 'locality.id', '=', 'locations.locality_id')
|
||||
->where('locations.is_public', true)->where('locations.is_primary', true)
|
||||
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
|
||||
->select('locations.existing_business_id', 'province.name as province')
|
||||
->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
|
||||
->selectRaw('NULL::text AS locality');
|
||||
|
||||
return ExistingBusiness::query()->published()->with('sector:id,name,slug')
|
||||
->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id')
|
||||
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title',
|
||||
'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective',
|
||||
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
|
||||
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
|
||||
'public_location.province', 'public_location.district', 'public_location.locality']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user