Preserva entrega A aceite de servicos em rascunho

This commit is contained in:
2026-10-25 22:02:44 +02:00
parent 26b80df545
commit ca13acb74e
31 changed files with 1152 additions and 17 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ final class Audit
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
{
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants']));
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id']));
DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
@@ -112,6 +112,7 @@ final class IdentityWorkflow
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get();
$grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id'])
->whereIn('permission', array_merge(Permissions::COMPANY, Permissions::UNIT))
->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']);
return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Modules\Services;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class DraftValues
{
public static function decimal(mixed $value, string $field, bool $positive = false): ?string
{
if ($value === null || $value === '') { return null; }
if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?$/D', $value)) {
throw ValidationException::withMessages([$field => 'Use uma cadeia decimal com ponto, até 16 algarismos inteiros e 8 decimais; sem expoentes ou arredondamento.']);
}
$decimal = BigDecimal::of($value);
if ($positive && $decimal->isLessThanOrEqualTo(BigDecimal::zero())) { throw ValidationException::withMessages([$field => 'A quantidade deve ser superior a zero quando preenchida.']); }
return (string) $decimal->stripTrailingZeros();
}
public static function lines(array $lines, array $scope): array
{
$result = [];
foreach ($lines as $index => $line) {
$service = DB::table('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first();
abort_unless($service, 403, 'Serviço fora da empresa autorizada.');
$result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name,
'description' => $line['description'] ?? null,
'quantity' => self::decimal($line['quantity'] ?? null, "lines.$index.quantity", true),
'unit_price' => self::decimal($line['unit_price'] ?? null, "lines.$index.unit_price")];
}
return $result;
}
public static function pending(array $data): array
{
$pending = ['tax_rule_pending' => 'Imposto por definir e validar; não foi considerado zero.',
'accounting_rule_pending' => 'Regras contabilísticas por validar; nenhum lançamento foi produzido.'];
if (empty($data['currency'])) { $pending['currency_missing'] = 'Moeda não indicada; nenhuma moeda foi presumida.'; }
else { $pending['currency_validation_pending'] = 'Moeda indicada pelo utilizador; a sua aplicabilidade e a moeda funcional continuam por validar.'; }
foreach ($data['lines'] as $index => $line) {
if ($line['quantity'] === null) { $pending["quantity_missing_$index"] = 'Quantidade por preencher na linha '.($index + 1).'.'; }
if ($line['unit_price'] === null) { $pending["price_missing_$index"] = 'Preço proposto por preencher na linha '.($index + 1).'.'; }
}
return $pending;
}
public static function base(array $data): ?string
{
$sum = BigDecimal::zero();
foreach ($data['lines'] ?? [] as $line) {
if ($line['quantity'] === null || $line['unit_price'] === null) { return null; }
$sum = $sum->plus(BigDecimal::of($line['quantity'])->multipliedBy($line['unit_price']));
}
return (string) $sum->stripTrailingZeros();
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Modules\Services\Http;
use App\Http\Controllers\Controller;
use App\Modules\Services\ServiceWorkflow;
use App\Modules\Services\ServiceAccess;
use App\Modules\Services\ServicePermissions;
use Illuminate\Http\Request;
final class ServiceController extends Controller
{
public function index(Request $request, string $area, ServiceWorkflow $workflow) { return response()->json(['records' => $workflow->list($area, $request->user()->id)]); }
public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return response()->json($workflow->show($area, $id, $request->user()->id)); }
public function create(Request $request, string $area, ServiceWorkflow $workflow) { return $this->save($request, $area, null, $workflow); }
public function update(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return $this->save($request, $area, $id, $workflow); }
private function save(Request $request, string $area, ?int $id, ServiceWorkflow $workflow)
{
abort_unless(in_array($area, ServicePermissions::AREAS, true), 404);
$rules = ['name' => ServicePermissions::unitScoped($area) ? 'sometimes|required|string|max:255' : 'required|string|max:255',
'description' => 'nullable|string|max:2000', 'version' => $id ? 'required|integer|min:1' : 'prohibited'];
if ($area === 'clients') { unset($rules['description']); }
if (ServicePermissions::unitScoped($area)) {
$rules += ['currency' => 'nullable|string|regex:/^[A-Z]{3}$/', 'lines' => $area === 'service_drafts' ? 'required|array|min:1|max:100' : 'sometimes|array|min:1|max:100',
'lines.*' => 'array:service_id,service_name,description,quantity,unit_price', 'lines.*.service_id' => 'required|integer|min:1',
'lines.*.service_name' => 'sometimes|string|max:255', 'lines.*.description' => 'nullable|string|max:2000', 'lines.*.quantity' => 'nullable|string|max:30', 'lines.*.unit_price' => 'nullable|string|max:30'];
if ($area === 'service_drafts') { $rules['client_id'] = 'required|integer|min:1'; }
else { $rules['source_id'] = $id ? 'prohibited' : 'required|integer|min:1'; $rules['reload_source'] = 'sometimes|boolean'; }
}
$unknown = array_diff(array_keys($request->all()), array_keys($rules));
abort_if($unknown !== [], 422, 'Campo não suportado ou identificador protegido.');
$data = $request->validate($rules);
$key = (string) $request->header('Idempotency-Key', '');
return response()->json($workflow->save($area, $id, $request->user()->id, $data, $key), $id ? 200 : 201);
}
public function accesses(Request $request, string $area, ServiceAccess $access) { return response()->json($access->listing($area, $request->user()->id)); }
public function changeAccess(Request $request, string $area, ServiceAccess $access)
{
$data = $request->validate(['email' => 'required|email|max:255', 'action' => 'required|string', 'grant' => 'required|boolean', 'reason' => 'required|string|max:500']);
abort_if(array_diff(array_keys($request->all()), ['email','action','grant','reason']) !== [], 422);
$access->change($area, $request->user()->id, $data);
return response()->json(['ok' => true]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?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 ServiceAccess
{
public function __construct(private ServiceWorkflow $workflow, private CompanyContext $context, private Permissions $permissions) {}
public function listing(string $area, int $actor): array
{
$scope = $this->workflow->scope($area, $actor, 'manage');
$grants = DB::table('permission_grants as g')->join('users as u', 'u.id', '=', 'g.user_id')->where('g.tenant_id', $scope['tenant_id'])->where('g.company_id', $scope['company_id'])->where('g.unit_id', $scope['unit_id'])->whereIn('g.permission', array_map(fn ($action) => "$area.$action", ServicePermissions::ACTIONS))
->orderBy('u.name')->get(['g.id','g.permission','g.unit_id','u.name','u.email']);
$actions = array_values(array_filter(ServicePermissions::ACTIONS, fn ($action) => $this->permissions->has($actor, $scope['tenant_id'], $scope['company_id'], "$area.$action", $scope['unit_id'])));
return ['grants' => $grants, 'actions' => $actions, 'scope' => $scope];
}
public function change(string $area, int $actor, array $input): void
{
$scope = $this->workflow->scope($area, $actor, 'manage'); $selected = $this->context->unitId();
DB::transaction(function () use ($area, $actor, $input, $scope, $selected): void {
DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selected);
$this->workflow->scope($area, $actor, 'manage');
$permission = "$area.".$input['action'];
abort_unless(in_array($input['action'], ServicePermissions::ACTIONS, true), 422);
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $permission, $scope['unit_id']);
$target = DB::table('users')->where('email', mb_strtolower($input['email']))->first(['id']);
abort_unless($target, 422, 'Utilizador inexistente; nenhuma conta é criada por concessão.');
$this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], 'company.view');
if ($scope['unit_id']) { $this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']); }
$query = DB::table('permission_grants')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('user_id', $target->id);
if ($input['grant']) {
if ($input['action'] !== 'view') { $this->permissions->require($target->id, $scope['tenant_id'], $scope['company_id'], "$area.view", $scope['unit_id']); }
DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $target->id, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]);
$changed = [['permission' => $permission, 'unit_id' => $scope['unit_id']]];
} else {
$query->whereIn('permission', $input['action'] === 'view' ? array_map(fn ($action) => "$area.$action", ServicePermissions::ACTIONS) : [$permission]);
$changed = $query->get(['permission','unit_id'])->map(fn ($row) => (array) $row)->all();
foreach ($changed as $row) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], $row['permission'], $scope['unit_id']); }
$query->delete();
}
Audit::record($input['grant'] ? 'a.access.granted' : 'a.access.revoked', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], $target->id,
['permission' => $permission, 'reason' => $input['reason'], 'grants' => $changed]);
});
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Modules\Services;
final class ServicePermissions
{
public const AREAS = ['clients', 'services', 'service_drafts', 'billing_drafts'];
public const ACTIONS = ['view', 'create', 'edit', 'manage'];
public const LABELS = ['clients' => 'Clientes', 'services' => 'Serviços', 'service_drafts' => 'Prestações em rascunho', 'billing_drafts' => 'Rascunhos de facturação'];
public static function unitScoped(string $area): bool { return in_array($area, ['service_drafts', 'billing_drafts'], true); }
public static function catalogue(bool $unit): array
{
$result = [];
foreach (self::AREAS as $area) {
if (self::unitScoped($area) === $unit) {
foreach (self::ACTIONS as $action) { $result[] = "$area.$action"; }
}
}
return $result;
}
}
+163
View File
@@ -0,0 +1,163 @@
<?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;
$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_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();
$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) {
$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);
}
}