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

582 lines
36 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Opis\JsonSchema\CompliantValidator;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Errors\ValidationError;
/** Explicit adapter of §25/26 to existing IDs and resource collections; never renumbers records. */
class OpportunityCanonicalContract
{
public const BASE_CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise'];
/** Availability of this bounded base-category delivery, without rewriting existing contracts/data.
* @param array<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function deliveryBlockers(array $payload): array
{
$errors = [];
if (! in_array($payload['opportunity_category'] ?? '', self::BASE_CATEGORIES, true) && ! app(OpportunityModularProfiles::class)->enabled()) {
$errors['canonical.opportunity_category'] = 'Categoria fora desta entrega: concessão, financiamento, fornecimento e outros permanecem indisponíveis. Os registos anteriores são preservados.';
}
if (($payload['opportunity_category'] ?? '') === 'real_estate') {
$property = $payload['real_estate'] ?? [];
foreach (['construction_status' => ['completed'], 'occupancy_status' => ['vacant'], 'transaction_type' => ['sale', 'lease']] as $field => $values) {
if (! empty($property[$field]) && ! in_array($property[$field], $values, true)) {
$errors['canonical.real_estate.'.$field] = 'Variante indisponível para submissão nesta entrega. Pode guardar e recuperar a preparação; estão incluídas venda e arrendamento de imóvel concluído e desocupado.';
}
}
if (array_filter($property['encumbrances'] ?? [], fn ($row) => ! empty($row['type']) && $row['type'] !== 'none')) {
$errors['canonical.real_estate.encumbrances'] = 'Imóvel com encargos: pode preparar e guardar; a submissão deste ramo não está incluída nesta entrega.';
}
if (! empty($property['max_built_area']) || ! empty($property['urban_limit_document_id'])) {
$errors['canonical.real_estate.max_built_area'] = 'Limitações urbanísticas adicionais: preparação disponível; este ramo de submissão aguarda demonstração própria.';
}
}
return $errors;
}
public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1';
/**
* @param array<array-key, mixed> $payload
* @return array<string,mixed>
*/
public function schema(bool $draft = false, array $payload = []): array
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
if ($bytes === false) {
throw new \RuntimeException('Opportunity schema unavailable.');
}
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
// The original conditions were omitted from the extracted Create/Patch definitions.
foreach (['opportunity', 'realEstate', 'landRights', 'businessTransaction'] as $name) {
$schema['$defs'][$name.'Create']['allOf'] = $schema['$defs'][$name]['allOf'] ?? [];
}
$schema = app(OpportunityCategoryFields::class)->enrich($schema);
$schema = app(OpportunityModularProfiles::class)->extend($schema);
$schema = app(OpportunityClassification::class)->extend($schema);
$schema = app(OpportunityInitiativeApplicability::class)->schema($schema, $payload);
$schema = $this->writeSchema($schema, $draft);
$properties = &$schema['$defs']['opportunityCreate']['properties'];
unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned.
$properties['sector_id'] = ['type' => 'integer', 'minimum' => 1];
if (! app(OpportunityInitiativeApplicability::class)->enabled($payload)) {
$properties['promoter_id'] = ['type' => ['string', 'integer']];
}
// References point to the existing collections; their metadata is recovered on the server.
foreach (['locations', 'documents', 'images'] as $field) {
$properties[$field]['items'] = ['type' => 'string', 'format' => 'uuid'];
}
$properties['evidences'] = ['type' => 'array', 'items' => ['$ref' => '#/$defs/evidenceCreate']];
if (! $draft) {
$properties['evidences']['minItems'] = 1;
}
$properties['evidences']['items'] = ['type' => 'object', 'additionalProperties' => false,
'properties' => ['title' => ['type' => 'string'], 'evidence_type' => ['type' => 'string'],
'document_id' => ['type' => 'string', 'format' => 'uuid'], 'description' => ['type' => 'string']],
...($draft ? [] : ['required' => ['title', 'evidence_type', 'document_id']])];
$schema['$ref'] = '#/$defs/opportunityCreate';
$schema['$id'] = 'urn:investmoz:'.self::VERSION.($draft ? ':draft' : ':submit');
return $schema;
}
/**
* @param array<string,mixed> $node
* @return array<string,mixed>
*/
private function writeSchema(array $node, bool $draft): array
{
if (isset($node['$ref']) && preg_match('~^#/\$defs/(.+)$~', $node['$ref'], $match)) {
// Collections in the normative Create still referenced response definitions.
$defs = ['product', 'asset', 'input', 'market', 'impact', 'moneyItem', 'milestone', 'license', 'risk', 'relation', 'employment', 'environmentalSocial', 'localizedPublication', 'realEstate', 'landRights', 'businessTransaction', 'parcel', 'improvement', 'community'];
if (in_array($match[1], $defs, true)) {
$node['$ref'] .= 'Create';
}
}
if ($draft) {
unset($node['required'], $node['minItems'], $node['minProperties'], $node['contains'], $node['allOf']);
if (isset($node['type'])) {
$node['type'] = array_values(array_unique([...(array) $node['type'], 'null']));
}
if (isset($node['enum']) && ! in_array(null, $node['enum'], true)) {
$node['enum'][] = null;
}
if (isset($node['minLength'])) {
$node['minLength'] = 0;
}
if (($node['const'] ?? null) === true) {
unset($node['const']);
$node['type'] = 'boolean';
}
}
foreach ($node as $key => $value) {
if (is_array($value)) {
if (in_array($key, ['properties', '$defs'], true)) {
foreach ($value as $name => $child) {
$value[$name] = $this->writeSchema($child, $draft);
}
$node[$key] = $value;
} elseif (in_array($key, ['items', 'if', 'then', 'else'], true)) {
$node[$key] = $this->writeSchema($value, $draft);
} elseif (in_array($key, ['allOf', 'oneOf', 'anyOf'], true)) {
$node[$key] = array_map(fn ($child) => $this->writeSchema($child, $draft), $value);
}
}
}
return $node;
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function draft(array $payload): array
{
abort_unless(config('portal.opportunity_specialized_drafts_enabled'), 422, 'Contrato especializado indisponível.');
$this->validateSchema($payload, true);
return $payload;
}
/** @param array<string,mixed> $payload */
private function validateSchema(array $payload, bool $draft): void
{
$schema = $this->schema($draft, $payload);
$validator = new CompliantValidator(null, 100, false);
$data = $this->schemaData($payload, $schema['$defs']['opportunityCreate'], $schema['$defs']);
$result = $validator->validate($data, json_decode(json_encode($schema, JSON_THROW_ON_ERROR)));
if ($error = $result->error()) {
throw ValidationException::withMessages($this->schemaValidationErrors($error));
}
}
/** @return array<string,list<string>> */
private function schemaValidationErrors(ValidationError $error): array
{
$errors = [];
$formatter = new ErrorFormatter;
$collect = function (ValidationError $node) use (&$collect, &$errors, $formatter): void {
if ($node->subErrors() !== []) {
foreach ($node->subErrors() as $child) {
$collect($child);
}
return;
}
$prefix = rtrim('canonical.'.implode('.', $node->data()->fullPath()), '.');
$args = $node->args();
if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) {
$errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.';
return;
}
if ($node->keyword() === 'required') {
foreach ($args['missing'] ?? [] as $field) {
$errors[$prefix.'.'.$field][] = 'Preencha este campo antes de submeter.';
}
return;
}
if ($node->keyword() === 'additionalProperties') {
$definition = $node->schema()->info()->data();
$declared = is_object($definition) ? (array) ($definition->properties ?? []) : [];
foreach ($args['properties'] ?? [] as $field) {
// Opis can report declared properties as unchecked after a sibling field fails.
// Keep real unknown-property errors without turning valid fields into errors.
if (! array_key_exists($field, $declared)) {
$errors[$prefix.'.'.$field][] = 'Campo não previsto no contrato desta categoria.';
}
}
return;
}
if ($node->keyword() === 'minLength') {
$errors[$prefix][] = 'Preencha este campo com pelo menos '.$args['min'].' caracteres; tem '.$args['length'].'.';
return;
}
if ($node->keyword() === 'maxLength') {
$errors[$prefix][] = 'Este campo admite até '.$args['max'].' caracteres; tem '.$args['length'].'.';
return;
}
foreach ($formatter->format($node) as $path => $messages) {
$key = rtrim('canonical'.str_replace('/', '.', $path), '.');
$errors[$key] = array_values(array_unique([...($errors[$key] ?? []), ...$messages]));
}
};
$collect($error);
// Formatting must never make an invalid schema result acceptable.
return $errors ?: ['canonical' => ['Não foi possível validar o contrato. Reveja os campos preenchidos.']];
}
/** @param array<string,mixed> $node
* @param array<string,mixed> $defs
*/
private function schemaData(mixed $value, array $node, array $defs): mixed
{
if (($node['x-investmoz-classification'] ?? false) && is_array($value)) {
$node = isset($value['classification']) ? $defs['relationCreate'] : ['type' => 'object'];
}
if (isset($node['$ref'])) {
$name = basename($node['$ref']);
$node = $defs[$name] ?? $node;
}
if (! is_array($value)) {
return $value;
}
if (in_array('object', (array) ($node['type'] ?? []), true) || isset($node['properties'])) {
$result = new \stdClass;
foreach ($value as $key => $item) {
$result->{$key} = $this->schemaData($item, $node['properties'][$key] ?? [], $defs);
}
return $result;
}
return array_map(fn ($item) => $this->schemaData($item, $node['items'] ?? [], $defs), $value);
}
/** @return array<string,mixed> */
public function validateSubmission(Opportunity $record): array
{
$values = app(OpportunityManagement::class)->values($record);
$payload = $values['canonical'] ?? [];
$errors = [];
$initiative = app(OpportunityInitiativeApplicability::class);
$initiative->validate($payload, $errors);
try {
$this->validateSchema($payload, false);
} catch (ValidationException $failure) {
throw ValidationException::withMessages([...$failure->errors(), ...$errors]);
}
$check = function (bool $valid, string $path, string $message) use (&$errors): void {
if (! $valid) {
$errors['canonical.'.$path] = $message;
}
};
$check(($payload['opportunity_category'] ?? null) === $record->opportunity_category, 'opportunity_category', 'A categoria deve coincidir com o registo e a autorização.');
$check($initiative->enabled($payload) || ($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.');
$previous = app(OpportunityManagement::class)->values($record)['canonical'] ?? [];
$sameTaxonomy = ($previous['sector_id'] ?? null) === ($payload['sector_id'] ?? null)
&& ($previous['subsector_id'] ?? null) === ($payload['subsector_id'] ?? null)
&& ($previous['economic_activity_id'] ?? null) === ($payload['economic_activity_id'] ?? null);
$check(app(EconomicReferenceCatalog::class)->validHierarchy((int) $payload['sector_id'], $payload['subsector_id'] ?? null, $payload['economic_activity_id'] ?? null, $sameTaxonomy),
'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.');
}
}
$resources = [];
foreach (['locations', 'documents', 'images'] as $kind) {
$table = app(OpportunityResources::class)->table($kind);
foreach ($payload[$kind] ?? [] as $index => $id) {
$row = DB::table($table)->where('id', $id)->where('opportunity_id', $record->id)->whereNull('deleted_at')->first();
$check($row !== null, "$kind.$index", 'Recurso inexistente, eliminado ou de outra oportunidade.');
if (! $row) {
continue;
}
$row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
if ($kind !== 'locations') {
$file = DB::table('documents')->where('id', $row->document_id)->first();
$check($file !== null && $file->deleted_at === null && $file->status === 'active' && $file->malware_scan_status === 'clean'
&& hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.');
if ($file) {
// Verify actual bytes/cipher integrity as well as the recorded scan hash.
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
}
} else {
$location = $row->payload;
// §10.2 area/unit consistency applies at submission. Resource drafts may
// retain incomplete measurements; never invent a unit or convert values.
$hasArea = isset($location['area_value']);
$hasUnit = isset($location['area_unit']) && $location['area_unit'] !== '';
$check(! $hasArea || $hasUnit, "locations.$index.area_unit", 'Indique a unidade da área antes de submeter; a preparação incompleta continua guardada nos recursos.');
$check(! $hasUnit || $hasArea, "locations.$index.area_value", 'Indique o valor da área antes de submeter; a unidade não determina o valor.');
$check(! $hasUnit || in_array($location['area_unit'], OpportunityResources::AREA_UNITS, true), "locations.$index.area_unit", 'Use uma unidade do catálogo existente: sqm, ha ou sqkm.');
$check(! $initiative->applicable($payload, 'physical_site') || (! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location)), "locations.$index", 'Localização requer distrito, papel e indicação de principal.');
app(InvestmentGeography::class)->selection($location, true);
$country = $location['country_code'] ?? null;
$check($country === 'MZ', "locations.$index.country_code", 'A hierarquia administrativa moçambicana deve declarar MZ; outros países exigem localização própria.');
}
$resources[$kind][$id] = (array) $row;
}
}
$locations = $resources['locations'] ?? [];
$primary = array_filter($locations, fn ($l) => $l['payload']['is_primary'] ?? false);
$check(! $initiative->applicable($payload, 'physical_site') || count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.');
$check(! $initiative->applicable($payload, 'physical_site') || ($payload['location_mode'] ?? null) !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.');
foreach ($payload['evidences'] as $i => $evidence) {
$check(isset($resources['documents'][$evidence['document_id']]), "evidences.$i.document_id", 'Evidência deve usar um documento ativo desta oportunidade incluído no contrato.');
}
$this->categoryRules($payload, $record, $resources, $errors);
app(OpportunityCategoryFields::class)->validate($payload, $record, $resources, $errors);
if ($errors) {
throw ValidationException::withMessages($errors);
}
ksort($payload);
$snapshot = ['adapter_version' => self::VERSION, 'schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')),
'payload' => $payload, 'resources' => $resources];
if (isset(OpportunityModularProfiles::PROFILES[$record->opportunity_category]) || ($payload['module_profile_version'] ?? '') === OpportunityModularProfiles::HISTORY_VERSION) {
$snapshot['qa_extension'] = ['version' => OpportunityModularProfiles::VERSION, 'profile_version' => $payload['module_profile_version'],
'functional_decision' => 'Experimental QA proposal; functional approval of modular contracts and SLA remains pending. Legacy integration authorization does not approve these modules.',
'gates' => $this->gates($record)];
if ($record->opportunity_category === 'other') {
$snapshot['qa_extension']['subtype'] = config('portal.opportunity_other_qa_subtypes.'.($payload['module_details']['subtype'] ?? ''), []);
}
// A documentary correction must not rewrite the checksum of an already
// signed, unchanged version. Its historical annotation is explicitly
// reconciled by the read adapter; new/changed content uses the current note.
$prior = $record->workflow_context['submission'] ?? [];
if (isset($prior['qa_extension']) && ($prior['payload'] ?? null) == $snapshot['payload'] && ($prior['resources'] ?? null) == $snapshot['resources']) {
$snapshot['qa_extension']['functional_decision'] = $prior['qa_extension']['functional_decision'];
}
}
$snapshot['hash'] = hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
return $snapshot;
}
/**
* @param array<string,mixed> $p
* @param array<string,mixed> $resources
* @param array<string,string> $errors
*/
private function categoryRules(array $p, Opportunity $record, array $resources, array &$errors): void
{
$category = $p['opportunity_category'];
app(OpportunityModularProfiles::class)->validate($p, $resources, $errors);
foreach (['real_estate', 'land_rights', 'business_transaction'] as $module) {
$applicable = match ($module) {
'real_estate' => ['real_estate'], 'land_rights' => ['land_rights'], default => ['business_sale', 'business_partnership', 'franchise']
};
if (isset($p[$module]) && ! in_array($category, $applicable, true)) {
$errors['canonical.'.$module] = 'Módulo incompatível com a categoria. Preserve-o noutra versão antes de mudar de categoria.';
}
}
if ($category === 'investment_project' && app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment')) {
$total = $p['total_investment'];
$sum = array_sum(array_column($p['investment_breakdown'], 'amount'));
$secured = array_sum(array_column($p['secured_financing'], 'amount'));
if (abs($sum - $total) > 0.010001 || $secured > $total || abs($p['funding_sought'] - ($total - $secured)) > 0.010001) {
$errors['canonical.investment_breakdown'] = 'Rubricas e financiamento devem reconciliar com o total (tolerância normativa 0,01).';
}
if (! in_array($p['maturity_stage'], array_keys(OpportunityMaturity::STAGES), true)) {
$errors['canonical.maturity_stage'] = 'Use um código explícito do catálogo de oito etapas; não reinterpretar a escala histórica.';
}
foreach (['investment_breakdown', 'secured_financing'] as $field) {
foreach ($p[$field] as $i => $amount) {
if ($amount['currency'] !== $p['currency']) {
$errors["canonical.$field.$i.currency"] = 'Moedas distintas requerem cenário/câmbio explícito; não somar valores de moedas diferentes.';
}
}
}
foreach ($p['markets'] as $i => $market) {
if (isset($market['country_code']) && Validator::make(['country' => $market['country_code']], ['country' => app(CountryCatalogue::class)->rule()])->fails()) {
$errors["canonical.markets.$i.country_code"] = 'Use país do catálogo ISO existente.';
}
}
}
if ($category === 'real_estate') {
$re = $p['real_estate'];
if ($re['transaction_type'] === 'sale' && ($re['price'] ?? 0) <= 0) {
$errors['canonical.real_estate.price'] = 'Venda exige preço positivo.';
}
if ($re['transaction_type'] === 'lease') {
if (($re['rent'] ?? 0) <= 0) {
$errors['canonical.real_estate.rent'] = 'Arrendamento exige renda positiva.';
}
if (empty($re['rent_periodicity'])) {
$errors['canonical.real_estate.rent_periodicity'] = 'Indique a periodicidade da renda.';
}
}
if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['property_condition'])) {
$errors['canonical.real_estate.property_condition'] = 'Imóvel existente exige condição identificada.';
}
if (in_array($re['construction_status'], ['planned', 'under_construction'], true) && empty($re['planning_permit_document_id'])) {
$errors['canonical.real_estate.planning_permit_document_id'] = 'Estado construtivo exige licença de construção (§25.1).';
}
if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['use_license_document_id'])) {
$errors['canonical.real_estate.use_license_document_id'] = 'Imóvel existente exige licença de utilização aplicável (§25.1).';
}
foreach (['planning_permit_document_id', 'use_license_document_id'] as $field) {
if (! empty($re[$field]) && ! isset($resources['documents'][$re[$field]])) {
$errors['canonical.real_estate.'.$field] = 'Licença deve referir documento ativo desta oportunidade.';
}
}
}
if ($category === 'land_rights') {
$land = $p['land_rights'];
if (! empty($land['issuing_authority_id']) && ! DB::table('authorities')->where('id', $land['issuing_authority_id'])->where('status', 'published')->exists()) {
$errors['canonical.land_rights.issuing_authority_id'] = 'Entidade emissora deve usar autoridade canónica publicada.';
}
if (! empty($land['issue_date']) && ($land['issue_date'] > today()->toDateString() || (! empty($land['expiry_date']) && $land['expiry_date'] <= $land['issue_date']))) {
$errors['canonical.land_rights.issue_date'] = 'Emissão não futura e validade posterior à emissão.';
}
foreach ($land['parcels'] as $i => $parcel) {
if (! isset($resources['locations'][$parcel['location_id']])) {
$errors["canonical.land_rights.parcels.$i.location_id"] = 'Parcela deve usar uma localização desta oportunidade.';
}
try {
$g = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_GeometryType(g)=\'ST_MultiPolygon\' AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) s', [json_encode($parcel['geometry'])]);
if (! $g?->valid) {
throw new \RuntimeException;
}
} catch (\Throwable) {
$errors["canonical.land_rights.parcels.$i.geometry"] = 'Parcela requer MultiPolygon válido, não vazio, EPSG:4326; limite administrativo não é parcela.';
}
}
if (in_array($land['duat_status'], ['provisional', 'definitive'], true) && empty($land['duat_number'])) {
$errors['canonical.land_rights.duat_number'] = 'DUAT provisório/definitivo exige número e entidade emissora.';
}
}
if (isset($p['business_transaction'])) {
$business = $p['business_transaction'];
if ($business['legal_entity_id'] !== $record->organization_id || ! DB::table('organizations')->where('id', $business['legal_entity_id'])->whereNull('deleted_at')->exists()) {
$errors['canonical.business_transaction.legal_entity_id'] = 'Entidade ativa representada obrigatória.';
}
}
}
/** @param array<string,mixed> $snapshot */
public function validatePublication(array $snapshot): void
{
$p = $snapshot['payload'];
$errors = [];
app(OpportunityCategoryFields::class)->publication($p, $errors);
app(OpportunityModularProfiles::class)->publication($p, $errors);
foreach ($snapshot['resources']['documents'] ?? [] as $id => $document) {
if (! empty($document['payload']['valid_until']) && $document['payload']['valid_until'] < today()->toDateString()) {
$errors['documents.'.$id] = 'Documento incluído expirou; preserve o original e reveja a versão.';
}
}
if ($p['opportunity_category'] === 'land_rights') {
foreach ($p['land_rights']['required_authorizations'] as $i => $authorization) {
if (! in_array($authorization['status'] ?? '', ['approved', 'not_required'], true)
|| (($authorization['status'] ?? '') === 'approved' && ! isset($snapshot['resources']['documents'][$authorization['document_id'] ?? '']))) {
$errors['land_rights.required_authorizations.'.$i] = 'Autorização externa pendente ou sem documento. Aprovação interna não substitui esse ato.';
}
}
foreach ($p['land_rights']['overlaps_conflicts'] as $i => $conflict) {
if (($conflict['type'] ?? '') !== 'none' && ($conflict['status'] ?? '') !== 'resolved') {
$errors['land_rights.overlaps_conflicts.'.$i] = 'Conflito impeditivo deve ser resolvido com evidência antes de publicar.';
}
}
}
if ($p['visibility'] === 'public') {
$images = $snapshot['resources']['images'] ?? [];
if (count(array_filter($images, fn ($image) => $image['payload']['is_cover'] ?? false)) !== 1) {
$errors['images'] = 'Publicação pública exige uma capa explícita.';
}
foreach ($images as $id => $image) {
$meta = $image['payload'];
if (empty($meta['alt_text']) || empty($meta['caption']) || empty($meta['rights_status']) || empty($meta['derivatives'])
|| (! empty($meta['rights_valid_until']) && $meta['rights_valid_until'] < today()->toDateString())) {
$errors['images.'.$id] = 'Legenda, texto alternativo, direitos válidos e derivadas verificadas obrigatórios.';
}
foreach ($meta['derivatives'] ?? [] as $derived) {
$file = DB::table('documents')->where('id', $derived['document_id'])->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->first();
if (! $file) {
$errors['images.'.$id] = 'Scan de uma derivada ainda não demonstrado.';
} else {
app(VerifiedDocumentBinary::class)->respond($file);
}
}
}
}
if ($errors) {
throw ValidationException::withMessages($errors);
}
}
/** @return list<string> */
public function gates(Opportunity $record): array
{
return match ($record->opportunity_category) {
'investment_project' => ['technical_review', 'legal_territorial_review'],
'real_estate' => ['property_review', 'registry_urban_review', 'building_compliance_review'],
'land_rights' => ['cadastral_screening', 'land_legal_review', 'community_environment_review', 'competent_authority_clearance'],
'business_sale' => ['corporate_screening', 'financial_review', 'legal_tax_due_diligence', 'confidentiality_clearance'],
'business_partnership' => ['corporate_screening', 'financial_review', 'partnership_governance_review', 'legal_tax_due_diligence'],
'franchise' => ['brand_license_review', 'financial_review', 'franchise_legal_review', 'confidentiality_clearance'],
default => app(OpportunityModularProfiles::class)->gates($record),
};
}
/** Same exact projection for preview and publication; never expose private business analysis.
* @param array<string,mixed> $payload
* @return array{title:string,summary:string,description:string} */
public function publicProjection(array $payload): array
{
$business = isset($payload['business_transaction']) && ($payload['visibility'] ?? '') === 'public';
$label = match ($payload['module_details']['mode'] ?? '') {
'intention_call' => 'Intenção/chamada em preparação — não é direito atribuído. ',
'awarded_right' => 'Concessão com ato competente documentado. ',
'seeking_funding' => 'Procura de financiamento. ', 'funding_offer' => 'Oferta de financiamento — sujeita a análise do beneficiário. ',
'purchase_request' => 'Pedido de compra. ', 'supply_offer' => 'Oferta de fornecimento. ', default => '',
};
if (($payload['module_details']['conditions_status'] ?? '') === 'proposed') {
$label .= 'Valores e condições propostos, sem compromisso assumido. ';
}
return ['title' => (string) ($payload['official_title'] ?? ''), 'summary' => Str::limit($label.(string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['executive_summary'] ?? '')), 499, '…'),
'description' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['demand_statement'] ?? ''))];
}
/** @return array<string,mixed> */
public function options(User $actor, ?Opportunity $record): array
{
$orgs = array_values(array_unique(array_column(app(PortalProfileAccess::class)->navigation($actor)['contexts'] ?? [], 'organization_id')));
if (! $record && (app(PortalProfileAccess::class)->navigation($actor)['internalPromoter'] ?? false)) {
// A staff creation scope does not expose unrelated private contact lists.
$orgs = [];
}
if ($record?->organization_id) {
$orgs = [$record->organization_id];
}
$contacts = DB::table('contacts')->whereNull('contacts.deleted_at')->where(function ($q) use ($orgs, $actor) {
$q->where(fn ($own) => $own->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$actor->id))
->orWhereExists(fn ($i) => $i->selectRaw('1')->from('investor_contacts as ic')->join('investors as inv', 'inv.id', '=', 'ic.investor_id')->whereColumn('ic.contact_id', 'contacts.id')->whereIn('inv.organization_id', $orgs)->whereNull('inv.deleted_at'));
})->get(['id', 'full_name']);
$submissionProperties = $this->schema(false)['$defs']['opportunityCreate']['properties'];
return ['version' => self::VERSION, 'modularProfiles' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::PROFILES : [],
'historyVersion' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::HISTORY_VERSION : null,
'classification' => app(OpportunityClassification::class)->options(),
'schema' => $this->schema(true)['$defs']['opportunityCreate'],
'definitions' => $this->schema(true)['$defs'],
'submissionTextLimits' => ['demand_statement' => array_intersect_key($submissionProperties['demand_statement'], array_flip(['minLength', 'maxLength']))],
'activities' => app(EconomicReferenceCatalog::class)->activities($record ? array_values(array_filter(array_intersect_key(app(OpportunityManagement::class)->values($record)['canonical'] ?? [], array_flip(['subsector_id', 'economic_activity_id'])))) : []),
'contacts' => $contacts, 'contactInvestors' => $record?->organization_id ? DB::table('investors')->where('organization_id', $record->organization_id)->whereNull('deleted_at')->whereIn('status', ['prospect', 'active'])->get(['id', 'legal_name']) : [],
'countries' => app(CountryCatalogue::class)->all(),
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
'gates' => $record ? $this->gates($record) : [], 'resources' => $record ? array_combine(['locations', 'documents', 'images'], array_map(fn ($k) => array_values(array_filter(app(OpportunityResources::class)->listing($record->id, $k), fn ($row) => empty($row->deleted_at))), ['locations', 'documents', 'images'])) : []];
}
}