Regista aceitacao funcional do primeiro incremento PGC-NIRF e preserva as dependencias documentais e tecnicas existentes. A validacao do calculo fiscal continua separada e pendente. Sem credenciais, bases operacionais ou publicacao.
145 lines
11 KiB
PHP
145 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),
|
|
'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);
|
|
}
|
|
}
|