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; } }