177 lines
13 KiB
PHP
177 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Arr;
|
|
|
|
/** §28.1: preserve signed decisions; only changed review scopes need new decisions. */
|
|
class OpportunityReviewMateriality
|
|
{
|
|
public const VERSION = 'review-scopes-20261012-v4';
|
|
|
|
// Preserve the projections used by earlier signed reviews.
|
|
public const BEFORE_FRANCHISE_FINANCE_VERSION = 'review-scopes-20261011-v3';
|
|
|
|
public const PREVIOUS_VERSION = 'review-scopes-20261011-v2';
|
|
|
|
/** @param array<string,mixed> $snapshot
|
|
* @return array<string,mixed> */
|
|
private function scope(array $snapshot, string $gate, bool $extended = true, bool $franchiseFinance = true): array
|
|
{
|
|
$p = $snapshot['payload'] ?? [];
|
|
$common = ['opportunity_category', 'promoter_id', 'promoter_type', 'sector_id', 'subsector_id', 'economic_activity_id', 'demand_statement', 'executive_summary'];
|
|
$fields = match ($gate) {
|
|
'property_review' => ['real_estate.property_type', 'real_estate.land_area', 'real_estate.built_area', 'real_estate.area_unit', 'real_estate.units', 'real_estate.occupancy_status', 'real_estate.price', 'real_estate.rent', 'real_estate.rent_periodicity', 'real_estate.valuation', 'real_estate.utilities'],
|
|
'registry_urban_review' => ['real_estate.transaction_type', 'real_estate.title_type', 'real_estate.registration_number', 'real_estate.encumbrances', 'real_estate.land_area', 'real_estate.area_unit'],
|
|
'building_compliance_review' => ['real_estate.construction_status', 'real_estate.property_condition', 'real_estate.built_area', 'real_estate.floors', 'real_estate.planning_permit_document_id', 'real_estate.use_license_document_id'],
|
|
'cadastral_screening' => ['land_rights.parcels', 'land_rights.overlaps_conflicts'],
|
|
'land_legal_review' => ['land_rights.operation_type', 'land_rights.duat_status', 'land_rights.duat_number', 'land_rights.holder_id', 'land_rights.holder_name', 'land_rights.issuing_authority_id', 'land_rights.issue_date', 'land_rights.expiry_date', 'land_rights.authorized_purpose', 'land_rights.improvements', 'land_rights.consideration'],
|
|
'community_environment_review' => ['land_rights.communities', 'land_rights.community_consultation', 'environmental_social'],
|
|
'competent_authority_clearance' => ['land_rights.required_authorizations', 'land_rights.exploitation_plan_document_id', 'land_rights.authorized_purpose'],
|
|
'corporate_screening' => ['business_transaction.legal_entity_id', 'business_transaction.legal_form', 'business_transaction.beneficial_owners', 'business_transaction.percentage_offered'],
|
|
'financial_review' => ['business_transaction.asking_price', 'business_transaction.valuation', 'business_transaction.financial_periods', 'business_transaction.assets_liabilities', 'business_transaction.employees', 'business_transaction.partner_contribution'],
|
|
'legal_tax_due_diligence' => ['business_transaction.transaction_type', 'business_transaction.percentage_offered', 'business_transaction.contracts_licenses', 'business_transaction.litigation_contingencies', 'business_transaction.governance_rights', 'business_transaction.franchise_terms', 'business_transaction.tax_labor_compliance'],
|
|
'partnership_governance_review' => ['business_transaction.percentage_offered', 'business_transaction.partner_contribution', 'business_transaction.governance_rights'],
|
|
'brand_license_review' => ['business_transaction.franchise_terms', 'business_transaction.contracts_licenses'],
|
|
'franchise_legal_review' => ['business_transaction.franchise_terms', 'business_transaction.contracts_licenses', 'business_transaction.litigation_contingencies'],
|
|
'confidentiality_clearance' => ['visibility', 'publish_contact', 'public_contact_id', 'business_transaction.confidentiality_level', 'business_transaction.teaser', 'business_transaction.nda_document_id', 'business_transaction.data_room_index_document_id'],
|
|
'technical_review' => ['demand_statement', 'opportunity_type', 'maturity_stage', 'investment_modalities', 'investor_profiles', 'products', 'assets', 'inputs', 'markets', 'competitive_advantage', 'employment', 'impact_indicators', 'total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators', 'milestones', 'risks'],
|
|
'legal_territorial_review' => ['licenses', 'environmental_social', 'relations', 'truth_declaration'],
|
|
// Experimental modules have no approved scope contract: never carry their decisions.
|
|
default => array_keys($p),
|
|
};
|
|
if ($extended) {
|
|
$fields = [...$fields, ...match ($gate) {
|
|
'land_legal_review' => ['land_rights.holder_document_id', 'land_rights.area_unit'],
|
|
'community_environment_review' => ['land_rights.communities_affected'],
|
|
'competent_authority_clearance' => ['land_rights.exploitation_plan_required'],
|
|
'corporate_screening' => ['business_transaction.entity_registration'],
|
|
'financial_review' => ['business_transaction.available_financial_years', 'business_transaction.history_status', 'business_transaction.history_declaration', 'business_transaction.history_declaration_document_id', 'business_transaction.projections'],
|
|
'franchise_legal_review' => ['business_transaction.legal_entity_id', 'business_transaction.legal_form', 'business_transaction.beneficial_owners', 'business_transaction.entity_registration', 'business_transaction.governance_rights', 'business_transaction.tax_labor_compliance'],
|
|
'confidentiality_clearance' => ['internal_contact_id'],
|
|
default => [],
|
|
}];
|
|
}
|
|
if ($franchiseFinance && $gate === 'financial_review' && ($p['opportunity_category'] ?? '') === 'franchise') {
|
|
// Existing canonical monetary obligations affect the existing financial review.
|
|
// No rates, calculations, reviewers or external decisions are introduced here.
|
|
$fields = [...$fields, 'business_transaction.franchise_terms.initial_fee', 'business_transaction.franchise_terms.royalties'];
|
|
}
|
|
$data = [];
|
|
foreach ([...$common, ...$fields] as $field) {
|
|
if (! is_string($field)) {
|
|
throw new \UnexpectedValueException('Canonical review fields must be named fields.');
|
|
}
|
|
$data[$field] = Arr::get($p, $field);
|
|
}
|
|
// Explicitly unclassified specialised fields conservatively reopen reviews.
|
|
// They cannot silently become immaterial when a schema gains a new field.
|
|
$classified = match ($p['opportunity_category'] ?? '') {
|
|
'real_estate' => ['transaction_type', 'property_type', 'construction_status', 'property_condition', 'land_area', 'built_area', 'area_unit', 'units', 'rooms', 'floors', 'amenities', 'utilities', 'occupancy_status', 'title_type', 'registration_number', 'encumbrances', 'price', 'rent', 'rent_periodicity', 'deposit', 'valuation', 'viewing', 'planning_permit_document_id', 'use_license_document_id', 'title_document_id', 'title_holder_name', 'issuing_authority_id', 'expected_completion_date', 'occupancy_details', 'occupancy_document_id', 'amenities_other', 'urban_limit_document_id', 'max_built_area'],
|
|
default => [],
|
|
};
|
|
if ($classified !== []) {
|
|
$data['unclassified_module_fields'] = array_diff_key($p['real_estate'] ?? [], array_flip($classified));
|
|
}
|
|
if ($gate === 'property_review') {
|
|
foreach (['rooms', 'amenities', 'deposit', 'occupancy_details', 'occupancy_document_id', 'amenities_other'] as $key) {
|
|
$data['real_estate.'.$key] = Arr::get($p, 'real_estate.'.$key);
|
|
}
|
|
}
|
|
if ($gate === 'registry_urban_review') {
|
|
foreach (['title_document_id', 'title_holder_name', 'issuing_authority_id', 'urban_limit_document_id', 'max_built_area'] as $key) {
|
|
$data['real_estate.'.$key] = Arr::get($p, 'real_estate.'.$key);
|
|
}
|
|
}
|
|
if ($gate === 'building_compliance_review') {
|
|
$data['real_estate.expected_completion_date'] = Arr::get($p, 'real_estate.expected_completion_date');
|
|
}
|
|
|
|
// A changed evidence file always invalidates a decision relying on it.
|
|
// Unrelated documents/images and editorial timestamps are not review evidence.
|
|
return $data;
|
|
}
|
|
|
|
/** @param array<string,mixed> $snapshot
|
|
* @param array<string,mixed> $review */
|
|
public function hash(array $snapshot, string $gate, array $review = [], bool $legacy = false, ?string $rule = null): string
|
|
{
|
|
$effectiveRule = $rule ?? self::VERSION;
|
|
$extended = ! $legacy && $effectiveRule !== self::PREVIOUS_VERSION;
|
|
$franchiseFinance = ! $legacy && ! in_array($effectiveRule, [self::PREVIOUS_VERSION, self::BEFORE_FRANCHISE_FINANCE_VERSION], true);
|
|
$data = $this->scope($snapshot, $gate, $extended, $franchiseFinance);
|
|
$locationGates = ['registry_urban_review', 'cadastral_screening', 'legal_territorial_review', 'property_review'];
|
|
if (! $legacy) {
|
|
$locationGates = [...$locationGates, 'land_legal_review', 'community_environment_review', 'competent_authority_clearance'];
|
|
}
|
|
if (in_array($gate, $locationGates, true)) {
|
|
$data['locations'] = array_map(fn ($r) => $r['payload'], $snapshot['resources']['locations'] ?? []);
|
|
}
|
|
foreach ($review['evidence_document_ids'] ?? [] as $id) {
|
|
$r = $snapshot['resources']['documents'][$id] ?? null;
|
|
$data['evidence'][$id] = $r ? ['sha256' => $r['sha256'] ?? null, 'payload' => $r['payload'] ?? [], 'deleted_at' => $r['deleted_at'] ?? null] : null;
|
|
}
|
|
|
|
if ($extended) {
|
|
// Nested references are part of the decision even when the reviewer
|
|
// selected a different supporting file. Preserve the original bytes.
|
|
$referenced = function (array $node) use (&$referenced, &$data, $snapshot): void {
|
|
foreach ($node as $key => $value) {
|
|
if (is_array($value)) {
|
|
$referenced($value);
|
|
} elseif (is_string($key) && is_string($value) && $value !== '' && (str_ends_with($key, 'document_id') || $key === 'image_id')) {
|
|
$kind = $key === 'image_id' ? 'images' : 'documents';
|
|
$resource = $snapshot['resources'][$kind][$value] ?? null;
|
|
$data['referenced_resources'][$kind][$value] = $resource ? ['sha256' => $resource['sha256'] ?? null, 'payload' => $resource['payload'] ?? [], 'deleted_at' => $resource['deleted_at'] ?? null] : null;
|
|
}
|
|
}
|
|
};
|
|
$referenced($data);
|
|
}
|
|
|
|
return $legacy ? hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)) : OpportunityCanonicalValue::hash($data);
|
|
}
|
|
|
|
/** @param array<string,mixed> $snapshot
|
|
* @param array<string,mixed> $review */
|
|
public function matches(array $snapshot, string $gate, array $review): bool
|
|
{
|
|
if (! isset($review['scope_hash'])) {
|
|
return true; // Caller must independently verify the original signed submission hash.
|
|
}
|
|
$rule = $review['scope_rule'] ?? '';
|
|
$legacy = ! in_array($rule, [self::VERSION, self::PREVIOUS_VERSION, self::BEFORE_FRANCHISE_FINANCE_VERSION], true);
|
|
$expected = $this->hash($snapshot, $gate, $review, $legacy, $rule);
|
|
|
|
return hash_equals($review['scope_hash'], $expected);
|
|
}
|
|
|
|
/** @param array<string,mixed> $before
|
|
* @param array<string,mixed> $after
|
|
* @param array<string,mixed> $reviews
|
|
* @param list<string> $gates
|
|
* @return array<string,mixed> */
|
|
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array
|
|
{
|
|
$kept = [];
|
|
$reopened = [];
|
|
foreach ($gates as $gate) {
|
|
$review = $reviews[$gate] ?? [];
|
|
// Compare the signed baseline and new content with the new scope;
|
|
// separately validate old scope signatures under their original rule.
|
|
$priorHash = $this->hash($before, $gate, $review);
|
|
$currentHash = $this->hash($after, $gate, $review);
|
|
if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
|
|
$kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'],
|
|
'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]];
|
|
} else {
|
|
$reopened[] = $gate;
|
|
}
|
|
}
|
|
|
|
return ['rule' => self::VERSION, 'reviews' => $kept, 'reopened' => $reopened, 'retained' => array_keys($kept),
|
|
'reason' => $force ? 'Suspensão, rejeição ou recurso exige nova decisão; motivo anterior preservado.' : 'Comparação dos campos e evidências aplicáveis a cada revisão (§28.1).',
|
|
'final_approval' => 'Nova assinatura independente da versão; nenhum parecer ou decisão externa inventado.'];
|
|
}
|
|
}
|