Files
SGF/app/Modules/Services/DocumentPreparation.php
T
nala f8d57b52aa Preserva classificacao contabilistica aceite e bases locais
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.
2026-10-26 13:47:11 +02:00

172 lines
16 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 Symfony\Component\HttpKernel\Exception\HttpException;
final class DocumentPreparation
{
public const PROFILE_FIELDS = ['nuit','address','locality','country'];
public const PAYMENT_FIELDS = ['label','bank','holder','account_number','nib','iban','swift','bank_code','currency','instructions'];
public const DRAFT_FIELDS = ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode','units','payment_instruction_ids'];
public function __construct(private CompanyContext $context, private Permissions $permissions, private ServiceWorkflow $workflow) {}
public function scope(string $kind, int $actor, string $action): array
{
abort_unless(in_array($kind, ['issuer','client','payment','service_drafts','billing_drafts'], true), 404);
$ids = $this->context->identifiers(); abort_unless($this->context->userId() === $actor, 403);
$unit = str_ends_with($kind, '_drafts') ? $this->context->unitId() : null;
abort_if(str_ends_with($kind, '_drafts') && ! $unit, 403, 'Seleccione uma unidade autorizada.');
$prefix = $kind === 'client' ? 'client_documents' : ($unit ? 'draft_documents' : 'issuer_documents');
foreach (array_unique(['view',$action]) as $right) { $this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$prefix.$right", $unit); }
return $ids + ['unit_id' => $unit];
}
private function query(string $kind, array $scope)
{
return DB::table('doc_records')->where($scope)->where('kind', $kind);
}
private function target(string $kind, int $id, array $scope): ?object
{
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind);
$query = DB::table($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
if ($scope['unit_id']) { $query->where('unit_id', $scope['unit_id']); }
$target = $query->first(); abort_unless($target, 403, 'Registo fora do âmbito autorizado.'); return $target;
}
public function index(string $kind, int $actor): array
{
$scope = $this->scope($kind, $actor, 'view');
if ($kind === 'payment') { return $this->query($kind, $scope)->orderBy('id')->get()->map(fn ($row) => $this->decode($row))->all(); }
if ($kind === 'issuer') { return [['id' => $scope['company_id'], 'name' => DB::table('companies')->where('id', $scope['company_id'])->value('name')]]; }
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
return $this->workflow->list($kind, $actor);
}
public function show(string $kind, int $id, int $actor): array
{
$scope = $this->scope($kind, $actor, 'view'); $target = $this->target($kind, $id, $scope);
abort_if($kind === 'payment' && ! $target, 403);
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
if ($scope['unit_id']) { $this->workflow->scope($kind, $actor, 'view'); }
$row = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
$record = $row ? $this->decode($row) : ['id' => null, 'version' => 0, 'data' => []];
$record['name'] = $target->name ?? ($record['data']['label'] ?? '');
$record['origin'] = $kind === 'payment' ? 'Instruções configuradas do emitente' : ($kind === 'issuer' ? 'Nome reutilizado do cadastro da empresa' : ($kind === 'client' ? 'Nome reutilizado do cadastro do cliente' : 'Rascunho existente na unidade seleccionada'));
$record['locked'] = $kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists();
$record['review'] = $this->review($kind, $record['data'], $target);
if ($scope['unit_id']) {
$data = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
$record['draft_version'] = (int) $target->version; $record['currency'] = $data['currency']; $record['lines'] = $data['lines'];
$record['review'][] = $this->check('currency', 'Moeda do rascunho', $data['currency'], 'Rascunho existente', 'Indicar moeda sem a presumir; validar aplicabilidade.');
$record['review'][] = ['field' => 'terms_due', 'label' => 'Condições e vencimento', 'state' => 'validation_pending', 'origin' => 'Preparação do rascunho', 'next' => 'Conferir prazo e vencimento proposto. Sem emissão efectiva ou convenção de contagem aprovada, a compatibilidade não é validada nem corrigida automaticamente.'];
if ($row && (int) ($record['data']['draft_version'] ?? 0) !== (int) $target->version) { $record['review'][] = ['field' => 'draft_version', 'label' => 'Rascunho alterado', 'state' => 'stale', 'origin' => 'Versão de origem', 'next' => 'Rever linhas, unidades e preparação antes de voltar a guardar.']; }
$record['related_profiles'] = [];
foreach (['issuer' => $scope['company_id'], 'client' => $target->client_id] as $relatedKind => $relatedId) {
$prefix = $relatedKind === 'issuer' ? 'issuer_documents.view' : 'client_documents.view';
if ($this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], $prefix)) { $related = $this->show($relatedKind, (int) $relatedId, $actor); $record['related_profiles'][$relatedKind] = ['name' => $related['name'], 'version' => $related['version'], 'review' => $related['review']]; }
}
}
return $record;
}
public function save(string $kind, int $id, int $actor, array $input): array
{
$scope = $this->scope($kind, $actor, 'edit'); $selected = $this->context->unitId();
try {
return DB::transaction(function () use ($kind, $id, $actor, $input, $scope, $selected): array {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selected); $this->scope($kind, $actor, 'edit');
$target = $this->target($kind, $id, $scope); abort_if($kind === 'payment' && $id && ! $target, 403);
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); }
$old = $kind === 'payment' ? $target : $this->query($kind, $scope)->where('record_id', $id)->first();
abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'Os dados mudaram; reabra o formulário.');
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? self::PAYMENT_FIELDS : self::DRAFT_FIELDS);
$data = array_intersect_key($input, array_flip($fields));
if ($scope['unit_id']) {
$this->workflow->scope($kind, $actor, 'view');
abort_if($kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'A fotografia e o rascunho B1 estão bloqueados.');
abort_unless((int) $target->version === $input['draft_version'], 409, 'O rascunho mudou; reveja a preparação.');
$draftData = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
abort_unless(count($data['units']) === count($draftData['lines']), 422, 'Indique uma unidade de medida, ou pendência, por linha existente.');
$data['payment_instruction_ids'] = array_values(array_unique($data['payment_instruction_ids']));
if ($data['payment_instruction_ids']) { $this->scope('payment', $actor, 'view'); }
foreach ($data['payment_instruction_ids'] as $instruction) { abort_unless($this->query('payment', ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => null])->where('id', $instruction)->exists(), 403, 'Instrução fora da empresa autorizada.'); }
if ($input['currency'] !== $draftData['currency']) {
$changes = ['version' => (int) $target->version, 'currency' => $input['currency']];
if ($kind === 'service_drafts') { $changes += ['client_id' => $target->client_id, 'lines' => $draftData['lines']]; }
$saved = $this->workflow->save($kind, $id, $actor, $changes, (string) \Illuminate\Support\Str::uuid());
$target->version = $saved['version'];
}
$data['draft_version'] = (int) $target->version;
}
$version = (int) ($old->version ?? 0) + 1;
$values = ['data' => json_encode((object) $data, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()];
if ($old) { $this->query($kind, $scope)->where('id', $old->id)->update($values); $record = $old->id; }
else { $record = DB::table('doc_records')->insertGetId($scope + $values + ['kind' => $kind, 'record_id' => $kind === 'payment' ? 0 : $id, 'created_at' => now()]); }
DB::table('doc_history')->insert(['doc_record_id' => $record, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]);
// Audit contains only identifiers/version; never bank identifiers or instructions.
Audit::record('documents.'.$kind.'.prepared', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => $kind, 'record_id' => $record, 'version' => $version]);
return ['id' => $kind === 'payment' ? $record : $id, 'version' => $version];
});
} catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a preparação. Nenhum valor das instruções foi incluído no erro; reabra e tente novamente.'); }
}
public function snapshot(int $draft, int $actor): ?array
{
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId();
$billing = DB::table('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
$rows = DB::table('doc_records')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where(function ($q) use ($billing, $draft) {
$q->where('kind', 'issuer')->orWhere(fn ($q) => $q->where('kind', 'client')->where('record_id', $billing->client_id))->orWhere(fn ($q) => $q->where('kind', 'billing_drafts')->where('record_id', $draft));
})->get();
if ($rows->isEmpty()) { return null; }
foreach (['issuer','client','billing_drafts'] as $kind) { $this->scope($kind, $actor, 'view'); }
$result = ['schema_version' => 2, 'records' => [], 'payment_instructions' => [], 'completeness' => [], 'validation_status' => 'not_fiscally_validated'];
foreach ($rows as $row) {
$decoded = $this->decode($row); $result['records'][] = $decoded;
$result['completeness'][$row->kind] = $this->show($row->kind, (int) $row->record_id, $actor)['review'];
if ($row->kind === 'billing_drafts') {
abort_unless((int) ($decoded['data']['draft_version'] ?? 0) === (int) $billing->version, 409, 'Reveja a preparação documental após a alteração do rascunho.');
foreach ($decoded['data']['payment_instruction_ids'] as $id) { $result['payment_instructions'][] = $this->show('payment', $id, $actor); }
}
}
return $result;
}
public function inheritToBilling(int $source, int $billing, int $actor, array $scope): void
{
if (! $this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], 'draft_documents.view', $scope['unit_id'])) { return; }
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
if (! $row) { return; }
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
if ((int) $data['draft_version'] !== (int) DB::table('a_service_drafts')->where('id', $source)->value('version')) { return; }
$data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
$id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
DB::table('doc_history')->insert(['doc_record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => $encoded, 'created_at' => now()]);
Audit::record('documents.billing_drafts.reused', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['record_id' => $id, 'version' => 1]);
}
private function decode(object $row): array { $result = (array) $row; $result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); return $result; }
private function check(string $field, string $label, mixed $value, string $origin, string $next): array { return ['field' => $field, 'label' => $label, 'state' => $value !== null && $value !== '' ? 'filled_not_validated' : 'missing', 'origin' => $origin, 'next' => $next]; }
private function review(string $kind, array $data, ?object $target): array
{
$fields = in_array($kind, ['issuer','client'], true) ? self::PROFILE_FIELDS : ($kind === 'payment' ? ['label','bank','holder','currency','instructions'] : ['service_date','period_start','period_end','payment_terms','proposed_due_date','price_mode']);
$labels = ['nuit' => 'NUIT', 'address' => 'Morada', 'locality' => 'Localidade', 'country' => 'País', 'label' => 'Designação', 'bank' => 'Banco (quando aplicável)', 'holder' => 'Titular (quando aplicável)', 'currency' => 'Moeda (quando aplicável)', 'instructions' => 'Instruções', 'service_date' => 'Data do serviço', 'period_start' => 'Início do período', 'period_end' => 'Fim do período', 'payment_terms' => 'Condições de pagamento', 'proposed_due_date' => 'Vencimento proposto', 'price_mode' => 'Modo de preço'];
$result = [];
if (in_array($kind, ['issuer','client'], true)) { $result[] = $this->check('name','Denominação', $target?->name, 'Cadastro existente (sem duplicação)', 'Editar o nome no cadastro original se necessário; validar denominação legal.'); }
foreach ($fields as $field) { $value = $data[$field] ?? null; if ($field === 'price_mode' && $value === 'undefined') { $value = null; } $result[] = $this->check($field, $labels[$field], $value, 'Preparação introduzida pelo utilizador', 'Completar se aplicável e validar; preenchido não significa fiscalmente validado.'); }
if (str_ends_with($kind, '_drafts')) { foreach ($data['units'] ?? [] as $i => $value) { $result[] = $this->check('unit_'.$i,'Unidade da linha '.($i + 1),$value,'Preparação da linha existente','Indicar unidade de medida se aplicável; nenhuma unidade é presumida.'); } }
$result[] = ['field' => 'legal', 'label' => 'Validação legal/fiscal', 'state' => 'validation_pending', 'origin' => 'Regras ainda por validar', 'next' => 'Validar fontes e regras aplicáveis. Este ecrã não autoriza emissão fiscal nem verifica dados junto de bancos.'];
return $result;
}
}