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.
171 lines
12 KiB
PHP
171 lines
12 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\Support\Facades\DB;
|
|
|
|
final class ServiceWorkflow
|
|
{
|
|
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
|
|
|
|
public function scope(string $area, int $actor, string $action): array
|
|
{
|
|
abort_unless(in_array($area, ServicePermissions::AREAS, true) && in_array($action, ServicePermissions::ACTIONS, true), 404);
|
|
$ids = $this->context->identifiers();
|
|
abort_unless($this->context->userId() === $actor, 403);
|
|
$unit = ServicePermissions::unitScoped($area) ? $this->context->unitId() : null;
|
|
abort_if(ServicePermissions::unitScoped($area) && $unit === null, 403, 'Seleccione uma unidade autorizada para esta área.');
|
|
$this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$area.view", $unit);
|
|
$this->permissions->require($actor, $ids['tenant_id'], $ids['company_id'], "$area.$action", $unit);
|
|
return $ids + ['unit_id' => $unit];
|
|
}
|
|
|
|
private function query(string $area, array $scope)
|
|
{
|
|
return DB::table('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']);
|
|
}
|
|
|
|
public function list(string $area, int $actor): array
|
|
{
|
|
$scope = $this->scope($area, $actor, 'view');
|
|
return $this->query($area, $scope)->orderByDesc('id')->get()->map(fn ($item) => $this->describe($area, $item))->all();
|
|
}
|
|
|
|
public function show(string $area, int $id, int $actor): array
|
|
{
|
|
$scope = $this->scope($area, $actor, 'view');
|
|
$record = $this->query($area, $scope)->where('id', $id)->first();
|
|
abort_unless($record, 403, 'Registo fora do âmbito autorizado.');
|
|
$result = $this->describe($area, $record);
|
|
$result['history'] = DB::table('a_history')->where('area', $area)->where('record_id', $id)->orderBy('version')
|
|
->get(['id','version','actor_id','snapshot','created_at'])->map(function ($row) {
|
|
$row->snapshot = json_decode($row->snapshot, true);
|
|
$row->event = DB::table('a_events as e')->join('a_outbox as o', 'o.event_id', '=', 'e.id')->where('e.history_id', $row->id)
|
|
->first(['e.id','e.type','e.schema_version','o.state']);
|
|
return $row;
|
|
})->all();
|
|
return $result;
|
|
}
|
|
|
|
private function describe(string $area, object $row): array
|
|
{
|
|
$result = (array) $row;
|
|
$result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
|
|
$result['version'] = (int) $row->version;
|
|
if ($area === 'billing_drafts') {
|
|
$confirmationId = DB::table('b_internal_confirmations')->where('billing_draft_id', $row->id)->value('id');
|
|
$result['internal_confirmation'] = $confirmationId ? ['reference' => 'INT-B1-'.$confirmationId, 'label' => InternalConfirmation::LABEL] : null;
|
|
}
|
|
$result['base_amount'] = ServicePermissions::unitScoped($area) ? DraftValues::base($result['data']) : null;
|
|
$history = DB::table('a_history')->where('area', $area)->where('record_id', $row->id)->where('version', $row->version)->value('id');
|
|
$result['pending'] = $history ? DB::table('a_pending')->where('history_id', $history)->pluck('message', 'code')->all() : [];
|
|
return $result;
|
|
}
|
|
|
|
public function save(string $area, ?int $id, int $actor, array $input, string $key): array
|
|
{
|
|
$action = $id === null ? 'create' : 'edit';
|
|
$scope = $this->scope($area, $actor, $action);
|
|
$selectedUnit = $this->context->unitId();
|
|
abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.');
|
|
$hash = hash('sha256', json_encode($this->canonical([$area, $id, $scope, $input]), JSON_THROW_ON_ERROR));
|
|
return DB::transaction(function () use ($area, $id, $actor, $input, $key, $scope, $selectedUnit, $hash, $action): array {
|
|
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
|
|
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selectedUnit);
|
|
$this->scope($area, $actor, $action);
|
|
$previous = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first();
|
|
if ($previous) {
|
|
abort_unless((int) $previous->actor_id === $actor && hash_equals($previous->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
|
|
return json_decode($previous->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
|
|
}
|
|
$old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null;
|
|
if ($id) {
|
|
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
|
|
abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.');
|
|
abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.');
|
|
}
|
|
$version = $old ? (int) $old->version + 1 : 1;
|
|
$values = $this->normalise($area, $input, $scope, $old);
|
|
$data = $values['data']; $values['data'] = json_encode($data === [] ? new \stdClass() : $data, JSON_THROW_ON_ERROR);
|
|
$values += ['version' => $version, 'updated_at' => now()];
|
|
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); }
|
|
else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); }
|
|
$record = $this->query($area, $scope)->where('id', $id)->first();
|
|
if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
|
|
$snapshot = (array) $record; $snapshot['data'] = $data;
|
|
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
|
|
if ($area === 'billing_drafts') {
|
|
$current = DB::table('a_service_drafts')->where('id', $record->source_id)->value('version');
|
|
if ((int) $current !== (int) $data['source_version']) { $pending['source_changed'] = 'A prestação tem uma versão mais recente. Reveja ou actualize explicitamente os dados de origem.'; }
|
|
}
|
|
$snapshot['pending'] = $pending;
|
|
$history = DB::table('a_history')->insertGetId($scope + ['area' => $area, 'record_id' => $id, 'version' => $version,
|
|
'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
foreach ($pending as $code => $message) { DB::table('a_pending')->insert(['history_id' => $history, 'code' => $code, 'message' => $message, 'created_at' => now()]); }
|
|
$affected = [];
|
|
if ($area === 'service_drafts' && $old) {
|
|
foreach (DB::table('a_billing_drafts')->where('source_id', $id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->get(['id','version']) as $billing) {
|
|
if (DB::table('b_internal_confirmations')->where('billing_draft_id', $billing->id)->exists()) { continue; }
|
|
$billingHistory = DB::table('a_history')->where('area', 'billing_drafts')->where('record_id', $billing->id)->where('version', $billing->version)->value('id');
|
|
DB::table('a_pending')->insertOrIgnore(['history_id' => $billingHistory, 'code' => 'source_changed', 'message' => 'A prestação de origem foi alterada; os dados deste rascunho não foram reescritos.', 'created_at' => now()]);
|
|
$affected[] = $billing->id;
|
|
}
|
|
}
|
|
$event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => "a.$area.".($old ? 'updated' : 'created'),
|
|
'schema_version' => 1, 'payload' => json_encode(['snapshot' => $snapshot, 'affected_billing_drafts' => $affected], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]);
|
|
Audit::record("a.$area.".($old ? 'updated' : 'created'), $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'],
|
|
details: ['area' => $area, 'record_id' => $id, 'version' => $version, 'event_id' => $event]);
|
|
$result = ['id' => $id, 'version' => $version, 'event_id' => $event];
|
|
DB::table('a_commands')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'actor_id' => $actor,
|
|
'command_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
return $result + ['replayed' => false];
|
|
});
|
|
}
|
|
|
|
private function normalise(string $area, array $input, array $scope, ?object $old): array
|
|
{
|
|
if (! ServicePermissions::unitScoped($area)) {
|
|
return ['name' => trim($input['name']), 'data' => $area === 'clients' ? [] : ['description' => $input['description'] ?? null]];
|
|
}
|
|
$oldData = $old ? json_decode($old->data, true) : [];
|
|
$actor = $this->context->userId();
|
|
foreach (['clients.view', 'services.view'] as $permission) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $permission); }
|
|
$source = null;
|
|
if ($area === 'billing_drafts') {
|
|
$sourceId = $old ? $old->source_id : $input['source_id'];
|
|
$source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
|
|
abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
|
|
$reload = ! $old || ($input['reload_source'] ?? false);
|
|
if ($reload) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'service_drafts.view', $scope['unit_id']); }
|
|
$base = $reload ? json_decode($source->data, true) : $oldData;
|
|
$clientId = $reload ? $source->client_id : $old->client_id;
|
|
$lines = $input['lines'] ?? $base['lines'];
|
|
$allowedIds = array_column($base['lines'], 'service_id');
|
|
foreach ($lines as $line) { abort_unless(in_array((int) $line['service_id'], $allowedIds, true), 422, 'Use os serviços da prestação de origem.'); }
|
|
$currency = array_key_exists('currency', $input) ? $input['currency'] : ($base['currency'] ?? null);
|
|
$sourceVersion = $reload ? (int) $source->version : (int) $oldData['source_version'];
|
|
} else {
|
|
$clientId = $input['client_id']; $lines = $input['lines']; $currency = $input['currency'] ?? null;
|
|
}
|
|
$client = DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first();
|
|
abort_unless($client, 403, 'Cliente fora da empresa autorizada.');
|
|
$data = ['description' => $input['description'] ?? ($base['description'] ?? ($oldData['description'] ?? null)), 'currency' => $currency,
|
|
'lines' => DraftValues::lines($lines, $scope), 'client_name' => $client->name, 'tax_status' => 'pending', 'accounting_status' => 'not_processed'];
|
|
$result = ['client_id' => (int) $clientId, 'state' => 'draft', 'data' => $data,
|
|
'name' => trim($input['name'] ?? ($old?->name ?? ($area === 'billing_drafts' ? 'Rascunho da prestação #'.$source->id : 'Prestação de '.$client->name)))];
|
|
if ($source) { $result['source_id'] = $source->id; $result['data']['source_version'] = $sourceVersion; }
|
|
return $result;
|
|
}
|
|
|
|
private function canonical(mixed $value): mixed
|
|
{
|
|
if (! is_array($value)) { return $value; }
|
|
if (! array_is_list($value)) { ksort($value); }
|
|
return array_map(fn ($entry) => $this->canonical($entry), $value);
|
|
}
|
|
}
|