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(); }); } /** * @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.entity_key', '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.']; }); } /** 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.' : ''), ]; }); } }