456 lines
30 KiB
PHP
456 lines
30 KiB
PHP
<?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;
|
||
abort_unless($divisions->contains(fn ($references) => $references->contains(fn ($a) => ! $sector || (int) $a->effective_sector_id === $sector)), 422,
|
||
'A classificação de actividades aplicável a este sector ainda não está disponível para esta consulta. O dossier mantém-se guardado; esta limitação não significa ausência de empresas.');
|
||
$activity = ! empty($fields['activity_id']) ? $activities->firstWhere('id', $fields['activity_id']) : null;
|
||
abort_if(! 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): 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";
|
||
$rows = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->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 || ! $divisions->has($row->code)) {
|
||
continue;
|
||
}
|
||
$references = $divisions->get($row->code);
|
||
if ($sector && ! $references->contains(fn ($a) => (int) $a->effective_sector_id === $sector)) {
|
||
continue;
|
||
}
|
||
if ($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' => $row->code, 'label' => $labels->count() === 1 ? $labels->first() : 'Grupo CAE '.$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' => $division],
|
||
'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 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];
|
||
}
|
||
|
||
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']), 'total' => null, 'first_year' => null, 'last_year' => null];
|
||
}
|
||
$safe['filters'] = Arr::only($row['filters'] ?? [], ['sector_id', 'activity_id', 'activity_label', 'division_code']);
|
||
$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.' : ''),
|
||
];
|
||
});
|
||
}
|
||
}
|