173 lines
16 KiB
PHP
173 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');
|
|
\App\Modules\Workshop\WorkshopCommercialState::assertCurrent($kind, $id);
|
|
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;
|
|
}
|
|
}
|