Files
SGF/app/Modules/Services/DocumentPreview.php
T

146 lines
11 KiB
PHP

<?php
namespace App\Modules\Services;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class DocumentPreview
{
public const CONFIGURE = 'document_preview.configure';
public const NOTICE = 'RASCUNHO — SEM VALIDADE FISCAL';
public const DEFAULT_MODEL = ['show_logo' => false, 'show_contacts' => true, 'show_observations' => true, 'show_payment_instructions' => true, 'contact_text' => '', 'observations' => ''];
public function __construct(private CompanyContext $context, private Permissions $permissions, private DocumentPreparation $preparation, private ServiceWorkflow $workflow) {}
private function consistent(callable $read): mixed
{
if (DB::transactionLevel() > 0) {
// RefreshDatabase fixtures already use a transaction. Production composition must start its own snapshot.
if (! app()->environment('testing')) { throw new HttpException(409, 'Reabra a consulta numa operação independente.'); }
return $read();
}
return DB::transaction(function () use ($read) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
return $read();
});
}
private function model(): array
{
$ids = $this->context->identifiers();
$row = DB::table('document_preview_models')->where($ids)->first();
return ['version' => (int) ($row->version ?? 0), 'data' => $row ? json_decode($row->data, true, 32, JSON_THROW_ON_ERROR) : self::DEFAULT_MODEL];
}
public function configuration(int $actor): array
{
return $this->consistent(function () use ($actor) { $this->preparation->scope('issuer', $actor, 'view'); return $this->model() + ['local_logo_available' => $this->logo() !== null]; });
}
public function configure(int $actor, array $input): array
{
$scope = $this->preparation->scope('issuer', $actor, 'view'); $unit = $this->context->unitId();
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
try { return DB::transaction(function () use ($scope, $unit, $actor, $input) {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $unit);
$this->preparation->scope('issuer', $actor, 'view');
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE);
$ids = ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']];
$old = DB::table('document_preview_models')->where($ids)->first();
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'A configuração mudou. Reabra-a antes de guardar.');
$data = array_intersect_key($input, self::DEFAULT_MODEL); $version = (int) ($old->version ?? 0) + 1;
$values = ['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()];
if ($old) { DB::table('document_preview_models')->where('id', $old->id)->update($values); $id = $old->id; }
else { $id = DB::table('document_preview_models')->insertGetId($ids + $values + ['created_at' => now()]); }
DB::table('document_preview_model_history')->insert(['model_id' => $id, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
Audit::record('document_preview.configured', $actor, $scope['tenant_id'], $scope['company_id'], details: ['record_id' => $id, 'version' => $version]);
return ['version' => $version];
}); } catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a configuração; nenhum conteúdo foi incluído no erro.'); }
}
public function read(int $draft, int $actor, bool $historical = false): array
{
return $this->consistent(function () use ($draft, $actor, $historical) {
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
if ($historical) { $payload = $this->historical($draft, $actor, $scope); }
else { $payload = $this->current($draft, $actor, $scope); }
$payload['notice'] = self::NOTICE;
// Fingerprint is keyed, so a change token cannot be used to guess bank identifiers.
$revision = hash_hmac('sha256', json_encode($payload, JSON_THROW_ON_ERROR), (string) config('app.key'));
return $payload + ['revision' => $revision, 'consulted_at' => now()->toIso8601String()];
});
}
private function current(int $draft, int $actor, array $scope): array
{
foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); }
$billing = $this->workflow->show('billing_drafts', $draft, $actor);
abort_if($billing['internal_confirmation'] !== null, 409, 'Este registo tem confirmação interna. Abra a vista histórica separada.');
$issuer = $this->preparation->show('issuer', $scope['company_id'], $actor);
$client = $this->preparation->show('client', $billing['client_id'], $actor);
$prepared = $this->preparation->show('billing_drafts', $draft, $actor);
$model = $this->model(); $payments = []; $paymentVersions = [];
foreach ($prepared['data']['payment_instruction_ids'] ?? [] as $id) {
$payment = $this->preparation->show('payment', (int) $id, $actor); $paymentVersions[] = [$payment['id'], $payment['version']];
if ($model['data']['show_payment_instructions']) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
}
$pending = $billing['pending'];
foreach (['Emitente' => $issuer, 'Cliente' => $client, 'Preparação' => $prepared] as $section => $row) {
foreach ($row['review'] as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$section.'.'.$check['field']] = $section.' — '.$check['label'].': '.$check['next']; } }
}
if (! $paymentVersions) { $pending['instructions_missing'] = 'Instruções de pagamento não seleccionadas; preparar apenas as aplicáveis.'; }
if (! $model['data']['contact_text']) { $pending['contacts_missing'] = 'Contactos de apresentação não configurados; completar se aplicável, independentemente da visibilidade do bloco.'; }
$localLogo = $this->logo(); $logo = $model['data']['show_logo'] ? $localLogo : null;
if (! $localLogo) { $pending['logo_missing'] = 'Logótipo opcional não disponível: falta recurso local autorizado da empresa. A escolha de visibilidade não valida este recurso.'; }
return ['mode' => 'draft', 'title' => 'Pré-visualização do rascunho de factura de serviço', 'reference' => 'Rascunho interno #'.$draft,
'scope' => $scope, 'source_id' => $billing['source_id'], 'draft_version' => $billing['version'], 'preparation_version' => $prepared['version'], 'model_version' => $model['version'],
'issuer' => ['name' => $issuer['name'], 'data' => $issuer['data']], 'client' => ['name' => $client['name'], 'data' => $client['data']],
'data' => $billing['data'], 'prepared' => $prepared['data'], 'base_amount' => $billing['base_amount'], 'pending' => $pending,
'calculation' => app(ServiceCalculation::class)->read($draft, $actor),
'fiscal_history' => $this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'service_fiscal.view') ? app(ServiceFiscal::class)->history($draft,$actor) : [],
'payments' => $payments, 'logo' => $logo, 'contacts' => $model['data']['show_contacts'] ? $model['data']['contact_text'] : null,
'observations' => $model['data']['show_observations'] ? $model['data']['observations'] : null,
'versions' => ['issuer' => $issuer['version'], 'client' => $client['version'], 'company_name' => $issuer['name'], 'client_name' => $client['name'], 'instructions' => $paymentVersions, 'model' => $model['version']]];
}
private function historical(int $draft, int $actor, array $scope): array
{
$confirmation = app(InternalConfirmation::class)->show($draft, $actor);
abort_unless($confirmation, 404, 'Não há confirmação interna histórica deste registo.');
$snapshot = $confirmation['snapshot']; $prepared = []; $issuer = []; $client = []; $payments = []; $pending = $snapshot['pending'];
$document = $snapshot['document_preparation'] ?? [];
foreach ($document['records'] ?? [] as $record) {
if ($record['kind'] === 'issuer') { $issuer = $record['data']; }
if ($record['kind'] === 'client') { $client = $record['data']; }
if ($record['kind'] === 'billing_drafts') { $prepared = $record['data']; }
}
foreach ($document['payment_instructions'] ?? [] as $payment) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; }
foreach ($document['completeness'] ?? [] as $kind => $checks) { foreach ($checks as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$kind.'.'.$check['field']] = $check['label'].': '.$check['next']; } } }
$pending['historical_limits'] = 'Logótipo, contactos, observações do modelo e denominação do emitente não foram capturados nesta fotografia. Não foram reconstruídos.';
return ['mode' => 'historical', 'title' => 'Confirmação interna histórica — sem validade fiscal', 'reference' => $confirmation['internal_reference'], 'scope' => $scope,
'confirmed_at' => $snapshot['confirmed_at'], 'actor_id' => $snapshot['actor_id'], 'snapshot_schema' => $snapshot['schema_version'] ?? 1, 'source_id' => $snapshot['service_draft']['id'],
'draft_version' => $snapshot['billing_draft']['version'], 'preparation_version' => null, 'model_version' => null,
'issuer' => ['name' => null, 'data' => $issuer], 'client' => ['name' => $snapshot['client']['name'], 'data' => $client],
'data' => $snapshot['data'], 'prepared' => $prepared, 'base_amount' => $snapshot['base_amount'], 'pending' => $pending, 'payments' => $payments,
'logo' => null, 'contacts' => null, 'observations' => null];
}
private function logo(): ?string
{
$company = $this->context->identifiers()['company_id'];
$disk = Storage::disk('local'); $path = 'document-logos/company-'.$company.'.png';
if (! $disk->exists($path) || $disk->size($path) > 524288) { return null; }
$bytes = $disk->get($path);
if (! str_starts_with($bytes, "\x89PNG\r\n\x1a\n") || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== 'image/png') { return null; }
return 'data:image/png;base64,'.base64_encode($bytes);
}
}