Files
SGF/app/Modules/Services/ServiceWorkflow.php
T

178 lines
13 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.');
if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
$column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id';
abort_if(DB::table('wb_generations')->where($column, $id)->exists(),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
}
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'];
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
abort_if(DB::table('wb_generations')->where('service_draft_id', $sourceId)->exists(),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
}
$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);
}
}