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

509 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Internal observations, never a public business directory or proof of demand. */
final class ProactiveSupply
{
public const LIMITATIONS = 'Informação administrativa histórica e incompleta. Actividade actual, capacidade, procura e compromissos por confirmar. Ausência de registos significa informação insuficiente, nunca ausência de oferta.';
public function authorize(User $actor, ?Opportunity $record = null): void
{
$access = app(PortalProfileAccess::class);
$internal = collect(OpportunityManagement::CATEGORIES)->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<string,mixed> $fields
* @return array<string,mixed>
*/
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<string,mixed> */
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<string,mixed> $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<int,\stdClass> */
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<array{id:string,code:string,label:string}> */
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<array<string,mixed>> $history
* @return list<array<string,mixed>>
*/
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<string,mixed>
*/
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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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<string,mixed> $input
* @return array<string,mixed> */
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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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<array-key, mixed>
*/
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.' : ''),
];
});
}
}