contains(fn ($type) => $access->internalAssignment($actor, $type) !== null); abort_unless($access->legacyWriteAllowed($actor) || $internal, 403); if ($record?->exists) { $access->authorize($actor, 'view', $record); } } private function read(User $actor, callable $callback): mixed { return DB::transaction(function () use ($actor, $callback) { DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)", [(string) $actor->id]); return $callback(); }); } /** Aggregate access never confers the existing internal row/source permission. */ public function aggregateAvailable(): bool { return (bool) config('investor-path.protected_supply_enabled') || (config('investor-path.aggregate_supply_qa_enabled') && IsolatedQaDatabase::allowsSchemaChanges()); } /** Fixed district/division cells; filters select released cells, never recalculate intersections. * @param array $fields * @return array */ public function protectedAggregate(User $actor, string $caseId, string $territory, array $fields): array { abort_unless(config('investor-path.protected_supply_enabled'), 404); $case = app(VirtualDesk::class)->case($actor, $caseId, true, true); abort_unless((int) $case->user_id === (int) $actor->id, 403); app(VirtualDesk::class)->assertOpen($case); $geo = app(InvestmentGeography::class); $path = collect($geo->path($territory)); $province = $path->firstWhere('location_type', 'province'); abort_unless($province !== null, 422, 'Guarde uma localização com província conhecida no dossier.'); $scope = $path->firstWhere('location_type', 'district') ?? $province; $nodes = $geo->publicReferences()->whereIn('id', $geo->descendants($province->id))->get(['id', 'parent_id', 'name', 'location_type'])->keyBy('id'); $mapping = []; $scopes = [$province->id => $province]; foreach ($nodes as $node) { $current = $node; for ($depth = 0; $current && $depth < 5; $depth++) { if ($current->location_type === 'district' || $current->location_type === 'province') { break; } $current = $nodes->get($current->parent_id); } if ($current && in_array($current->location_type, ['district', 'province'], true)) { $mapping[$node->id] = $current->id; $scopes[$current->id] = $current; } } $activities = $this->publicAggregateActivities(); $divisions = $activities->filter(fn ($a) => preg_match('/^[0-9]{2}$/D', $a->code) === 1)->groupBy('code'); $sector = isset($fields['sector_id']) ? (int) $fields['sector_id'] : null; $sourcePlan = ! empty($fields['supply_classification_key']) ? $this->sourceClassificationPlan($fields['supply_classification_key']) : null; abort_unless($sourcePlan || $divisions->contains(fn ($references) => $references->contains(fn ($a) => ! $sector || (int) $a->effective_sector_id === $sector)), 422, 'Escolha a actividade a comparar na classificação das fontes. O sector do dossier mantém-se guardado; não se presume uma equivalência. Esta limitação não significa ausência de empresas.'); $activity = ! $sourcePlan && ! empty($fields['activity_id']) ? $activities->firstWhere('id', $fields['activity_id']) : null; abort_if(! $sourcePlan && ! empty($fields['activity_id']) && ! $activity, 422, 'A actividade guardada não está disponível nesta classificação. Reveja o contexto.'); abort_if($activity && $sector && (int) $activity->effective_sector_id !== $sector, 422, 'A actividade não corresponde ao sector guardado. Reveja o contexto.'); $rawActivityCode = $activity?->code; abort_if($activity && ! is_string($rawActivityCode), 422, 'A referência de actividade não tem um código válido.'); $activityCode = is_string($rawActivityCode) ? str_replace('.', '', $rawActivityCode) : null; abort_if($activityCode && ! preg_match('/^[0-9]{2,5}$/D', $activityCode), 422, 'Seleccione uma actividade com código CAE disponível.'); $division = $activityCode ? substr($activityCode, 0, 2) : null; $previous = DB::selectOne("select current_setting('app.actor_user_id',true) as actor,current_setting('app.proactive_supply_read',true) as scope"); return $this->read($actor, function () use ($mapping, $scopes, $province, $scope, $territory, $path, $divisions, $sector, $division, $activity, $previous, $sourcePlan): array { try { $parts = []; $bindings = []; foreach ($mapping as $id => $parent) { $parts[] = 'WHEN ? THEN ?'; $bindings[] = $id; $bindings[] = $parent; } $scopeSql = 'CASE o.territory_id::text '.implode(' ', $parts).' ELSE NULL END'; $codeSql = "CASE WHEN replace(o.cae,'.','') ~ '^[0-9]{2,5}$' THEN substring(replace(o.cae,'.','') from 1 for 2) ELSE NULL END"; $query = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash'); if ($sourcePlan) { $this->joinSourceClassification($query, $sourcePlan); $codeSql = $this->sourceClassificationExpression(); } $rows = $query->whereIn('o.territory_id', array_keys($mapping)) ->whereRaw("s.receipt->>'candidate' = ? AND s.receipt->>'access' = 'internal'", ['a022ed9848b41ba342b4209414c2bbf2ebd0a7a3']) ->whereNull('o.duplicate_of')->where('o.identity_conflict', false)->whereRaw("trim(o.name) <> ''") // The accepted importer uses an observation-specific key when NUIT is unresolved. // Those keys must never masquerade as distinct documentary entities in the cohort. ->whereRaw("o.entity_key <> encode(sha256(convert_to('unresolved:' || o.source_hash || '|' || o.sheet || '|' || o.row_number::text,'UTF8')),'hex')") ->selectRaw($scopeSql.' as scope_id,'.$codeSql.' as code,count(DISTINCT o.entity_key) as entities,count(*) as observations', $bindings) ->groupByRaw('1,2')->orderByRaw('1,2')->get(); $groups = []; foreach ($rows as $row) { // Suppressed and absent cells have the same public representation. No marginal totals. if ((int) $row->entities < 5 || ! $row->code || (! $sourcePlan && ! $divisions->has($row->code))) { continue; } $references = $divisions->get($row->code, collect()); if (! $sourcePlan && $sector && ! $references->contains(fn ($a) => (int) $a->effective_sector_id === $sector)) { continue; } if (($sourcePlan && $row->code !== $sourcePlan['selected']['group']) || (! $sourcePlan && $division && $row->code !== $division)) { continue; } if ($scope->id !== $province->id && ! in_array($row->scope_id, [$scope->id, $province->id], true)) { continue; } $labels = $references->pluck('label_pt_mz')->unique(); $cellScope = $scopes[$row->scope_id]; $groups[] = ['code' => $sourcePlan ? $sourcePlan['selected']['group_code'] : $row->code, 'label' => $sourcePlan ? $sourcePlan['selected']['group_label'] : ($labels->count() === 1 ? $labels->first() : 'Grupo CAE '.$row->code), ...($sourcePlan ? ['classification_key' => $row->code] : []), 'total' => null, 'entities_band' => $this->countBand((int) $row->entities), 'observations_band' => $this->countBand((int) $row->observations), 'scope' => ['id' => $cellScope->id, 'name' => $cellScope->name, 'level' => $cellScope->location_type], 'first_year' => null, 'last_year' => null]; } $years = []; $unknownPeriod = false; foreach (DB::table('proactive_supply_sources')->whereRaw("receipt->>'candidate' = ? AND receipt->>'access' = 'internal'", ['a022ed9848b41ba342b4209414c2bbf2ebd0a7a3'])->pluck('period') as $period) { preg_match_all('/\b(?:19|20)[0-9]{2}\b/', $period, $matches); if (! $matches[0]) { $unknownPeriod = true; } $years = [...$years, ...$matches[0]]; } sort($years); $period = $years ? $years[0].($years[0] !== $years[array_key_last($years)] ? '–'.$years[array_key_last($years)] : '') : 'Por confirmar'; return ['privacy_version' => 1, 'privacy_policy' => 'fixed_district_cae_division_v1', 'requested_territory' => ['id' => $territory, 'name' => $path->last()->name], 'scope' => ['id' => $scope->id, 'name' => $scope->name, 'level' => $scope->location_type], 'broader_context' => $scope->id !== $territory, 'entities' => null, 'repeated' => null, 'uncertain' => null, 'groups' => $groups, 'truncated' => false, 'coverage_unknown' => true, 'filters' => ['sector_id' => $sector, 'activity_id' => $activity?->id, 'activity_label' => $activity?->label_pt_mz, 'division_code' => $sourcePlan ? $sourcePlan['selected']['group_code'] : $division, ...($sourcePlan ? ['classification_key' => $sourcePlan['selected']['id'], 'classification_label' => $sourcePlan['selected']['label'], 'classification_basis' => 'Hierarquia e designações do exemplar eBAU já utilizado nas fontes; correspondência por código ou designação exacta, sem equivalência presumida ao sector do dossier.'] : [])], 'source_summary' => ['label' => 'Base administrativa empresarial histórica incorporada no portal', 'period' => $period, 'incomplete_periods' => $unknownPeriod], 'limitations' => self::LIMITATIONS, 'interpretation' => 'Intervalos de cinco identidades documentais, nunca empresas únicas confirmadas. Observações consolidadas e identidades são medidas distintas. Uma identidade pode constar em várias actividades; não some grupos. Grupos com menos de cinco identidades, conflitos e dados sem classificação publicada não são divulgados; nenhum resultado pode significar ausência de registos ou protecção de informação.', 'privacy_notice' => 'A precisão mínima é distrital e a actividade é apresentada por divisão CAE. Não há totais marginais, contagens exactas, filtros livres ou datas por grupo. Dados apenas provinciais são contexto provincial, não implantação no distrito. Estes controlos reduzem a identificação indirecta; não constituem garantia matemática contra conhecimento externo.']; } finally { DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read',?,true)", [$previous->actor ?? '', $previous->scope ?? '']); } }); } private function countBand(int $count): string { $lower = intdiv($count, 5) * 5; return $lower.'–'.($lower + 4); } /** @return array */ private function sourceClassificationPlan(string $key): array { $nodes = collect(app(EconomicReferenceCatalog::class)->supplyClassification()['nodes']); $selected = $nodes->firstWhere('id', $key); abort_unless($selected !== null, 422, 'A classificação seleccionada não consta do exemplar disponível.'); $codes = $labels = []; foreach ($nodes->groupBy(fn ($n) => mb_strtoupper(str_replace('.', '', trim($n['code'])))) as $code => $items) { if ($items->pluck('group')->unique()->count() === 1) { $codes[] = [(string) $code, $items->first()['group']]; } } foreach ($nodes->groupBy(fn ($n) => mb_strtolower(trim($n['label']))) as $label => $items) { if ($items->pluck('group')->unique()->count() === 1) { $labels[] = [(string) $label, $items->first()['group']]; } } return ['selected' => $selected, 'codes' => $codes, 'labels' => $labels]; } /** @param array $plan */ private function joinSourceClassification(Builder $query, array $plan): void { foreach (['codes' => ['class_code', 'upper(replace(btrim(o.cae),\'.\',\'\'))'], 'labels' => ['class_label', "translate(lower(btrim(o.activity)),'ÁÀÂÃÉÊÍÓÔÕÚÜÇ','áàâãéêíóôõúüç')"]] as $kind => [$alias, $expression]) { $rows = $plan[$kind]; $values = implode(',', array_fill(0, count($rows), '(?::text,?::text)')); $query->leftJoin(DB::raw('(VALUES '.$values.') AS '.$alias.'(value,group_id)'), function ($join) use ($alias, $expression) { $join->on(DB::raw($expression), '=', $alias.'.value'); })->addBinding(array_merge(...$rows), 'join'); } } /** @return literal-string */ private function sourceClassificationExpression(): string { // Already accepted tourism import: documentary scope is accommodation, not a certified hotel subtype. return "CASE WHEN o.source_hash='f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c' AND btrim(o.cae)='' AND o.activity='Alojamento (âmbito do exemplar; categoria por conferir)' THEN 'ebau:158' WHEN class_code.group_id IS NOT NULL THEN CASE WHEN btrim(o.activity)='' OR class_label.group_id=class_code.group_id THEN class_code.group_id ELSE NULL END ELSE class_label.group_id END"; } /** @return Collection */ private function publicAggregateActivities(): Collection { $rows = app(EconomicReferenceCatalog::class)->activities()->keyBy('id'); return $rows->map(function ($row) use ($rows) { $result = clone $row; $current = $row; $result->effective_sector_id = null; for ($i = 0; $current && $i < 10; $i++) { if ($current->sector_id !== null) { $result->effective_sector_id = (int) $current->sector_id; break; } $current = $current->parent_id ? $rows->get($current->parent_id) : null; } return $result; })->values(); } /** @return list */ public function aggregateActivityOptions(?int $sector): array { $rows = $this->publicAggregateActivities(); $divisions = $rows->filter(fn ($a) => preg_match('/^[0-9]{2}$/D', $a->code) === 1)->pluck('code')->all(); $result = []; foreach ($rows as $a) { if (! is_string($a->id) || ! is_string($a->code) || ! is_string($a->label_pt_mz)) { continue; } if (($sector && $a->effective_sector_id !== $sector) || ! preg_match('/^[0-9]{2,5}$/D', str_replace('.', '', $a->code)) || ! in_array(substr(str_replace('.', '', $a->code), 0, 2), $divisions, true)) { continue; } $result[] = ['id' => $a->id, 'code' => $a->code, 'label' => $a->label_pt_mz]; } foreach (app(EconomicReferenceCatalog::class)->supplyClassification()['nodes'] as $node) { $result[] = ['id' => $node['id'], 'code' => $node['code'], 'label' => $node['label']]; } return $result; } /** Old exact QA snapshots stay stored, but are not exposed by the protected capability. * @param list> $history * @return list> */ public function protectedHistory(array $history): array { $result = []; foreach ($history as $row) { if (($row['privacy_version'] ?? null) !== 1 || ($row['privacy_policy'] ?? null) !== 'fixed_district_cae_division_v1') { continue; } $safe = Arr::only($row, ['privacy_version', 'privacy_policy', 'requested_territory', 'scope', 'broader_context', 'truncated', 'coverage_unknown', 'limitations', 'interpretation', 'privacy_notice', 'context_revision', 'recorded_at', 'operation_key']); $safe['entities'] = $safe['repeated'] = $safe['uncertain'] = null; $safe['groups'] = []; foreach ($row['groups'] ?? [] as $group) { if (! preg_match('/^[0-9]{2}$/D', $group['code'] ?? '') || ! $this->validBand($group['entities_band'] ?? '') || ! $this->validBand($group['observations_band'] ?? '')) { continue; } $safe['groups'][] = [...Arr::only($group, ['code', 'label', 'entities_band', 'observations_band', 'scope', 'classification_key']), 'total' => null, 'first_year' => null, 'last_year' => null]; } $safe['filters'] = Arr::only($row['filters'] ?? [], ['sector_id', 'activity_id', 'activity_label', 'division_code', 'classification_key', 'classification_label', 'classification_basis']); $safe['source_summary'] = Arr::only($row['source_summary'] ?? [], ['label', 'period', 'incomplete_periods']); $result[] = $safe; } return $result; } private function validBand(string $band): bool { return preg_match('/^([0-9]{1,12})–([0-9]{1,12})$/uD', $band, $m) === 1 && (int) $m[1] >= 5 && (int) $m[1] % 5 === 0 && (int) $m[2] - (int) $m[1] === 4; } /** No names, contacts, row identifiers or free-text activity descriptions leave this boundary. * @return array */ public function aggregate(User $actor, string $territory): array { abort_unless(config('investor-path.aggregate_supply_qa_enabled') && IsolatedQaDatabase::allowsSchemaChanges(), 404); $path = app(InvestmentGeography::class)->path($territory); abort_if($path === [], 422, 'Confirme primeiro a localização do dossier.'); return $this->read($actor, function () use ($path, $territory): array { $labels = app(EconomicReferenceCatalog::class)->activities()->groupBy('code')->map(fn ($rows) => $rows->pluck('label_pt_mz')->unique()->count() === 1 ? $rows->first()->label_pt_mz : null); $scope = collect($path)->last(); $query = function (string $id): Builder { return DB::table('proactive_supply_observations as o')->whereIn('o.territory_id', app(InvestmentGeography::class)->descendants($id)); }; $base = $query($territory); if (! (clone $base)->exists() && in_array($scope->location_type, ['locality', 'admin_post'], true)) { $district = collect($path)->firstWhere('location_type', 'district'); if ($district && $query($district->id)->exists()) { $scope = $district; $base = $query($district->id); } } $counts = (clone $base)->selectRaw('count(DISTINCT entity_key) FILTER (WHERE duplicate_of IS NULL AND NOT identity_conflict) AS entities, count(*) FILTER (WHERE duplicate_of IS NOT NULL) AS repeated, count(*) FILTER (WHERE identity_conflict) AS uncertain')->first(); $code = "CASE WHEN cae ~ '^[0-9.]{1,10}$' THEN cae ELSE '' END"; $groups = (clone $base)->whereNull('duplicate_of')->where('identity_conflict', false)->selectRaw($code." AS code, count(DISTINCT entity_key) AS total, min(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS first_year, max(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS last_year") ->groupByRaw($code)->orderByRaw($code)->limit(101)->get()->map(fn ($row) => ['code' => $row->code, 'label' => $row->code ? ($labels->get($row->code) ?? 'CAE '.$row->code.' — descrição por confirmar') : 'Actividade por classificar', 'total' => (int) $row->total, 'first_year' => $row->first_year, 'last_year' => $row->last_year])->all(); return ['requested_territory' => ['id' => $territory, 'name' => collect($path)->last()->name], 'scope' => ['id' => $scope->id, 'name' => $scope->name, 'level' => $scope->location_type], 'broader_context' => $scope->id !== $territory, 'entities' => (int) $counts->entities, 'repeated' => (int) $counts->repeated, 'uncertain' => (int) $counts->uncertain, 'groups' => array_slice($groups, 0, 100), 'truncated' => count($groups) > 100, 'coverage_unknown' => true, 'limitations' => self::LIMITATIONS, 'interpretation' => 'Os grupos ajudam a investigar actividades similares e complementares. A relação deve ser fundamentada; não demonstra concorrência efectiva, clientes, capacidade ou receitas. Uma entidade pode constar em vários grupos; não some os grupos.']; }); } /** * @param array $input * @return array */ public function search(User $actor, array $input, ?Opportunity $record = null): array { $this->authorize($actor, $record); $v = Validator::make($input, [ 'territory_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')], 'activity' => 'nullable|string|max:150', 'sector' => 'nullable|string|max:250', 'page' => 'nullable|integer|min:1|max:100000', ])->validate(); if (! Schema::hasTable('proactive_supply_observations')) { return ['installed' => false, 'rows' => [], 'total' => null, 'limitations' => self::LIMITATIONS, 'status' => 'Base interna ainda não carregada neste ambiente.']; } return $this->read($actor, function () use ($v) { $ids = [$v['territory_id']]; // Canonical descendants, not a name-only cross-province match. $front = $ids; for ($depth = 0; $depth < 5 && $front; $depth++) { $front = DB::table('geo_locations')->whereIn('parent_id', $front)->whereNull('deleted_at')->pluck('id')->all(); $ids = [...$ids, ...$front]; } $base = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->whereIn('o.territory_id', $ids); $sectors = (clone $base)->where('o.sector', '<>', '')->distinct()->orderBy('o.sector')->pluck('o.sector')->all(); if (! empty($v['sector'])) { $base->where('o.sector', $v['sector']); } if (! empty($v['activity'])) { $base->where(fn ($q) => $q->where('o.cae', 'ilike', '%'.$v['activity'].'%')->orWhereRaw("translate(lower(o.activity),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ?", ['%'.strtolower(Str::ascii($v['activity'])).'%'])); } $page = $v['page'] ?? 1; // Exact repeated observations are retained in storage, counted separately. $total = (clone $base)->whereNull('o.duplicate_of')->count(); $duplicates = (clone $base)->whereNotNull('o.duplicate_of')->count(); $rows = (clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page - 1) * 25)->limit(25)->get(['o.id', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector', 'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 's.name as source_name', 's.period as source_period', 's.imported_at']); $coverage = DB::table('proactive_supply_observations')->selectRaw("count(*) FILTER (WHERE territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE territory_resolution='province_only_district_unknown') AS province_only")->first(); return ['installed' => true, 'rows' => $rows, 'total' => $total, 'duplicate_sightings' => $duplicates, 'page' => $page, 'per_page' => 25, 'sectors' => $sectors, 'territory_coverage' => $coverage, 'limitations' => self::LIMITATIONS, 'status' => $total ? 'Registos documentais encontrados; não equivalem a empresas únicas ou oferta actual confirmada.' : 'Informação insuficiente nesta base e nestes filtros.']; }); } /** Review all retained observations, including those that cannot enter a territorial query. * @param array $input * @return array */ public function review(User $actor, array $input, ?Opportunity $record = null): array { $this->authorize($actor, $record); $filters = Validator::make($input, [ 'source_hash' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/D'], 'observation_id' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/D'], 'q' => ['nullable', 'string', 'max:120'], 'status' => ['nullable', Rule::in(['all', 'unmapped', 'ambiguous', 'territory_unavailable', 'identity_conflict', 'repeated', 'unclassified', 'unnamed'])], 'page' => ['nullable', 'integer', 'min:1', 'max:100000'], ])->validate(); $filters['status'] = $filters['status'] ?? 'unmapped'; $filters['page'] = (int) ($filters['page'] ?? 1); if (! Schema::hasTable('proactive_supply_observations') || ! Schema::hasTable('proactive_supply_sources')) { return ['installed' => false, 'filters' => $filters, 'sources' => [], 'coverage' => null, 'rows' => [], 'total' => null, 'per_page' => 25, 'limitations' => self::LIMITATIONS, 'status' => 'Base interna ainda não carregada neste ambiente.']; } return $this->read($actor, function () use ($filters): array { $sources = DB::table('proactive_supply_sources')->orderBy('name')->orderBy('sha256')->get(['sha256', 'name', 'period', 'limitations', 'imported_at']); if (! empty($filters['source_hash']) && ! $sources->contains('sha256', $filters['source_hash'])) { throw ValidationException::withMessages(['source_hash' => 'A origem escolhida não está disponível nesta base interna.']); } $base = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash') ->leftJoinSub(app(InvestmentGeography::class)->publicReferences()->select('id', 'name', 'location_type'), 'territory', 'territory.id', '=', 'o.territory_id'); if (! empty($filters['source_hash'])) { $base->where('o.source_hash', $filters['source_hash']); } $counts = (clone $base)->selectRaw("count(*) AS observations, count(*) FILTER (WHERE o.duplicate_of IS NULL) AS distinct_observations, count(*) FILTER (WHERE o.duplicate_of IS NOT NULL) AS repeated, count(*) FILTER (WHERE o.territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE o.territory_resolution='ambiguous') AS ambiguous, count(*) FILTER (WHERE o.territory_id IS NOT NULL AND territory.id IS NULL) AS territory_unavailable, count(*) FILTER (WHERE o.identity_conflict) AS identity_conflict, count(*) FILTER (WHERE btrim(o.activity)='') AS unclassified, count(*) FILTER (WHERE btrim(o.name) IN ('','Denominação não indicada')) AS unnamed")->first(); $coverage = array_map(fn ($value) => (int) $value, (array) $counts); $this->reviewStatus($base, $filters['status']); if (! empty($filters['observation_id'])) { $base->where('o.id', $filters['observation_id']); } if (! empty($filters['q'])) { $term = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower(Str::ascii(trim($filters['q'])))); $base->whereRaw("translate(lower(concat_ws(' ',o.name,o.province,o.district,o.cae,o.activity)),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ? ESCAPE '\\'", ['%'.$term.'%']); } $total = (clone $base)->count(); $filters['page'] = min($filters['page'], max(1, (int) ceil($total / 25))); $rows = $base->orderBy('o.source_hash')->orderBy('o.sheet')->orderBy('o.row_number')->orderBy('o.id') ->offset(($filters['page'] - 1) * 25)->limit(25) ->get(['o.id', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector', 'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 'o.duplicate_of', 's.name as source_name', 's.period as source_period', 's.imported_at', 'territory.name as canonical_territory', 'territory.location_type as canonical_type']); return ['installed' => true, 'filters' => $filters, 'sources' => $sources, 'coverage' => $coverage, 'rows' => $rows, 'total' => $total, 'per_page' => 25, 'limitations' => self::LIMITATIONS, 'status' => $total ? 'Observações administrativas para conferência; não são empresas únicas nem prova de actividade actual.' : 'Informação insuficiente nesta base e nestes filtros.']; }); } private function reviewStatus(Builder $query, string $status): void { match ($status) { 'unmapped' => $query->whereNull('o.territory_id'), 'ambiguous' => $query->where('o.territory_resolution', 'ambiguous'), 'territory_unavailable' => $query->whereNotNull('o.territory_id')->whereNull('territory.id'), 'identity_conflict' => $query->where('o.identity_conflict', true), 'repeated' => $query->whereNotNull('o.duplicate_of'), 'unclassified' => $query->whereRaw("btrim(o.activity)=''"), 'unnamed' => $query->whereRaw("btrim(o.name) IN ('','Denominação não indicada')"), default => $query, }; } /** Resolve server-side source lineage; a caller cannot fabricate an internal observation. * @param array $input * @return array */ public function comparisons(User $actor, array $input, ?Opportunity $record): array { $this->authorize($actor, $record); $v = Validator::make(['rows' => $input], [ 'rows' => 'array|max:40', 'rows.*' => 'array:observation_id,relationship,rationale,limitations', 'rows.*.observation_id' => 'required|string|size:64|distinct', 'rows.*.relationship' => 'required|in:competition,complementarity,partner,uncertain', 'rows.*.rationale' => 'required|string|max:4000', 'rows.*.limitations' => 'required|string|max:4000', ])->validate()['rows']; if (! $v) { return []; } abort_unless(Schema::hasTable('proactive_supply_observations'), 422, 'Base interna indisponível.'); return $this->read($actor, function () use ($v) { $fingerprints = []; foreach ($v as $row) { $o = DB::table('proactive_supply_observations')->where('id', $row['observation_id'])->first(); if (! $o) { throw ValidationException::withMessages(['proactive.supply_comparisons' => 'Registo da oferta indisponível; preserve a referência anterior e confira o ambiente.']); } if ($o->duplicate_of !== null || isset($fingerprints[$o->fingerprint])) { throw ValidationException::withMessages(['proactive.supply_comparisons' => 'Esta observação administrativa é repetida. Compare a referência original uma única vez; a repetição e a sua fonte permanecem conservadas na base interna.']); } $fingerprints[$o->fingerprint] = true; } return $v; }); } /** * @return array */ public function evidence(User $actor, string $id, ?Opportunity $record): array { $this->authorize($actor, $record); return $this->read($actor, function () use ($id) { $o = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->where('o.id', $id)->first(['o.*', 's.name as source_name', 's.period as source_period']); if (! $o) { throw ValidationException::withMessages(['proactive.evidence' => 'Observação interna indisponível neste ambiente.']); } return ['observation_id' => $o->id, 'category' => 'supply', 'purpose' => 'local', 'access' => 'internal', 'document_id' => null, 'statement' => 'Registo administrativo: '.$o->name.'; CAE '.$o->cae.'; '.($o->activity ?: 'actividade por classificar').'; '.$o->district.', '.$o->province.'. Não confirma actividade actual.', 'source' => $o->source_name.'; SHA256 '.$o->source_hash, 'locator' => $o->sheet.'!linha '.$o->row_number, 'period' => $o->source_date.'; '.$o->source_period, 'scope' => $o->district.', '.$o->province, 'coverage' => self::LIMITATIONS.($o->identity_conflict ? ' Identificador partilhado por denominações divergentes: identidade por conferir.' : ''), ]; }); } }