Files

214 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Query\Builder;
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();
});
}
/**
* @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.' : ''),
];
});
}
}