commit13
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\SgfCandidate;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tempo\Purchasing\Internal\InternalRequisitions;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
|
||||
final class Controller
|
||||
{
|
||||
private function project(Port $port, array $record): array
|
||||
{
|
||||
$actions = [];
|
||||
if ($record['status'] === 'draft' && $record['requester_id'] === $port->actorId()) {
|
||||
try {
|
||||
$port->authorize('save', $record);
|
||||
$actions = ['edit', 'submit'];
|
||||
} catch (HttpException $e) {
|
||||
if (! in_array($e->getStatusCode(), [403, 404], true)) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
} elseif (($next = InternalRequisitions::nextStep($record, $port->actorId())) !== null) {
|
||||
try {
|
||||
$port->authorize('step:'.$next['role'], $record);
|
||||
$actions = ['approved'];
|
||||
$s = $port->scope();
|
||||
if (app(Permissions::class)->has($port->actorId(), $s['tenant_id'], $s['company_id'], 'purchasing_budget.record', $s['unit_id'])) {
|
||||
$actions[] = 'rejected';
|
||||
}
|
||||
} catch (HttpException $e) {
|
||||
if (! in_array($e->getStatusCode(), [403, 404], true)) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $record + ['allowed_actions' => $actions];
|
||||
}
|
||||
|
||||
private function port(Request $request, string $key = ''): Port
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return new Port($actor, $key);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$port = $this->port($request);
|
||||
$data = $port->atomic(function () use ($port) {
|
||||
if (! $port->query('policies')->exists()) {
|
||||
$port->authorize('configure', null);
|
||||
|
||||
return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => [], 'centres' => [], 'configurationSources' => $port->configurationSources(), 'policy' => null, 'permissions' => ['configure' => true, 'prepare' => false]];
|
||||
}
|
||||
$port->authorize('view', null);
|
||||
$records = [];
|
||||
foreach ($port->query('requests')->orderByDesc('id')->limit(100)->pluck('id') as $id) {
|
||||
$record = $port->load((int) $id);
|
||||
try {
|
||||
$port->authorize('view', $record);
|
||||
$records[] = $this->project($port, $record);
|
||||
} catch (HttpException $e) {
|
||||
if ($e->getStatusCode() !== 404) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
$policy = $port->setup();
|
||||
|
||||
return ['context' => 'procurement.internal', 'scope' => $port->scope(), 'records' => $records, 'centres' => $port->allowedCentres(), 'configurationSources' => $port->configurationSources(), 'policy' => $port->has('configure') ? $policy : array_intersect_key($policy, array_flip(['revision'])),
|
||||
'permissions' => array_combine(['prepare', 'configure', 'department_head', 'procurement', 'finance'], array_map(fn ($p) => $port->has($p), ['prepare', 'configure', 'approve.department_head', 'approve.procurement', 'approve.finance']))];
|
||||
});
|
||||
|
||||
return response()->json($data)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$port = $this->port($request);
|
||||
$result = $port->atomic(function () use ($port, $id) {
|
||||
$record = $port->load($id);
|
||||
$port->authorize('view', $record);
|
||||
|
||||
return ['record' => $this->project($port, $record), 'history' => $port->query('events')->where('request_id', $id)->orderBy('version')->get()];
|
||||
});
|
||||
|
||||
return response()->json($result)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function command(Request $request, string $action, ?int $id = null)
|
||||
{
|
||||
$meta = $request->validate(['operation_key' => 'required|uuid', 'version' => ($id === null ? 'nullable' : 'required').'|integer|min:1']);
|
||||
$port = $this->port($request, $meta['operation_key']);
|
||||
$input = $request->except(['operation_key', 'version']);
|
||||
if ($action === 'submit') {
|
||||
abort_unless($input === [], 422, 'Campo protegido.');
|
||||
}
|
||||
if ($action === 'decide') {
|
||||
$input = Validator::make($input, ['decision' => 'required|in:approved,rejected', 'comment' => 'nullable|required_if:decision,rejected|string|max:2000'])->validate();
|
||||
}
|
||||
try {
|
||||
$result = (new InternalRequisitions($port))->command($action, $id, $meta['version'] ?? null, $meta['operation_key'], $input);
|
||||
} catch (RequisitionFailure $e) {
|
||||
return response()->json(['message' => $e->getMessage()], $e->status);
|
||||
}
|
||||
|
||||
return response()->json($result)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function configure(Request $request)
|
||||
{
|
||||
$port = $this->port($request);
|
||||
$data = $request->validate(['version' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'enabled' => 'required|boolean', 'budget_required' => 'required|boolean',
|
||||
'first_limit_minor' => 'required|integer|min:0|max:100000000000000', 'second_limit_minor' => 'required|integer|min:0|max:100000000000000', 'approval_includes_tax' => 'required|boolean',
|
||||
'centres' => 'required|array|min:1', 'centres.*.id' => 'required|integer|distinct|min:1', 'centres.*.department_ref' => 'required|string|max:100',
|
||||
'members' => 'required|array|min:1', 'members.*.user_id' => 'required|integer|distinct|min:1', 'members.*.department_ref' => 'required|string|max:100']);
|
||||
abort_if(array_diff(array_keys($request->all()), array_keys($data)) !== [], 422, 'Campo protegido.');
|
||||
$port->atomic(function () use ($port, $data) {
|
||||
$port->authorize('configure', null);
|
||||
$scope = $port->scope();
|
||||
$old = $port->query('policies')->lockForUpdate()->first();
|
||||
abort_unless((int) ($old->version ?? 0) === $data['version'], 409, 'Política alterada.');
|
||||
abort_unless($data['second_limit_minor'] >= $data['first_limit_minor'], 422, 'Limites inválidos.');
|
||||
foreach ($data['centres'] as $c) {
|
||||
abort_unless(DB::table('cb_centres')->where($scope)->where('id', $c['id'])->exists(), 403, 'Centro não autorizado.');
|
||||
}
|
||||
foreach ($data['members'] as $m) {
|
||||
abort_unless(DB::table('company_memberships')->where(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'user_id' => $m['user_id']])->exists(), 403, 'Pessoa não autorizada.');
|
||||
abort_unless(app(Permissions::class)->has($m['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']), 403, 'Unidade não autorizada.');
|
||||
}
|
||||
$payload = $data;
|
||||
unset($payload['version']);
|
||||
$payload['rules_provisional'] = false;
|
||||
$values = ['version' => $data['version'] + 1, 'actor_id' => $port->actorId(), 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'created_at' => now()];
|
||||
if ($old) {
|
||||
$port->query('policies')->update($values);
|
||||
} else {
|
||||
DB::table('ir_policies')->insert($scope + $values);
|
||||
}
|
||||
DB::table('ir_policy_versions')->insert($scope + $values);
|
||||
});
|
||||
|
||||
return response()->json(['configured' => true, 'version' => $data['version'] + 1]);
|
||||
}
|
||||
|
||||
public function page(Request $request)
|
||||
{
|
||||
$request->headers->set('X-Purchasing-Context', 'procurement.internal');
|
||||
$port = $this->port($request);
|
||||
$port->atomic(fn () => $port->authorize($port->query('policies')->exists() ? 'view' : 'configure', null));
|
||||
$selection = $request->session()->get('company_context', []);
|
||||
$scope = $port->scope();
|
||||
$contract = ['csrf' => csrf_token(), 'token' => $selection['token'] ?? '', 'scope' => $scope, 'context' => 'procurement.internal', 'actor_id' => $port->actorId(),
|
||||
'company' => DB::table('companies')->where('id', $scope['company_id'])->value('name'), 'unit' => DB::table('units')->where('id', $scope['unit_id'])->value('name')];
|
||||
ob_start();
|
||||
require __DIR__.'/screen.php';
|
||||
$html = ob_get_clean();
|
||||
|
||||
return response($html)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function script(Request $request)
|
||||
{
|
||||
return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type', 'text/javascript')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\SgfCandidate;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tempo\Purchasing\Internal\InternalRequisitions;
|
||||
use Tempo\Purchasing\Internal\RequisitionPort;
|
||||
|
||||
final class Port implements RequisitionPort
|
||||
{
|
||||
private array $scope;
|
||||
|
||||
public function __construct(private User $actor, private string $originKey = '')
|
||||
{
|
||||
$ctx = app(CompanyContext::class);
|
||||
$this->scope = $ctx->identifiers() + ['unit_id' => $ctx->unitId()];
|
||||
}
|
||||
|
||||
public function actorId(): int
|
||||
{
|
||||
return $this->actor->id;
|
||||
}
|
||||
|
||||
public function scope(): array
|
||||
{
|
||||
return $this->scope;
|
||||
}
|
||||
|
||||
public function query(string $table): Builder
|
||||
{
|
||||
return DB::table('ir_'.$table)->where($this->scope);
|
||||
}
|
||||
|
||||
public function atomic(callable $work): mixed
|
||||
{
|
||||
BudgetAdapter::guard();
|
||||
abort_unless(config('purchasing-internal.candidate_enabled') === true, 404);
|
||||
|
||||
return DB::transaction(function () use ($work) {
|
||||
$s = $this->scope;
|
||||
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->actor = User::query()->lockForUpdate()->findOrFail($this->actor->id);
|
||||
$ctx = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $this->actor->id && $ctx->userId() === $this->actor->id && $s['unit_id'] !== null
|
||||
&& $ctx->identifiers() + ['unit_id' => $ctx->unitId()] === $s, 409, 'Contexto alterado.');
|
||||
$ctx->selectFor($this->actor->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
|
||||
abort_unless(request()->header('X-Purchasing-Context') === 'procurement.internal', 403, 'Escolha Compras internas.');
|
||||
if (request()->hasSession() && request()->is('api/*')) {
|
||||
abort_unless(hash_equals(request()->session()->get('company_context.token', ''), (string) request()->header('X-SGF-Context')), 409, 'O contexto mudou.');
|
||||
}
|
||||
|
||||
return $work();
|
||||
});
|
||||
}
|
||||
|
||||
public function has(string $right): bool
|
||||
{
|
||||
$s = $this->scope;
|
||||
|
||||
return app(Permissions::class)->has($this->actor->id, $s['tenant_id'], $s['company_id'], 'purchasing_requisitions.'.$right, $s['unit_id']);
|
||||
}
|
||||
|
||||
public function setup(): array
|
||||
{
|
||||
$row = $this->query('policies')->first();
|
||||
abort_unless($row, 422, 'Configure a política e os cadastros autorizados.');
|
||||
|
||||
return json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR) + ['revision' => (int) $row->version];
|
||||
}
|
||||
|
||||
private function department(): ?string
|
||||
{
|
||||
foreach ($this->setup()['members'] as $member) {
|
||||
if ($member['user_id'] === $this->actor->id) {
|
||||
return $member['department_ref'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function allowedCentres(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->setup()['centres'] as $c) {
|
||||
if ($this->has('configure') || $c['department_ref'] === $this->department()) {
|
||||
$name = DB::table('cb_centres')->where($this->scope)->where('id', $c['id'])->value('code');
|
||||
$out[] = $c + ['label' => $name ?? 'Centro configurado'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function configurationSources(): array
|
||||
{
|
||||
if (! $this->has('configure')) {
|
||||
return [];
|
||||
}
|
||||
$s = $this->scope;
|
||||
|
||||
return ['centres' => DB::table('cb_centres')->where($s)->get(['id', 'code'])->all(),
|
||||
'members' => DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id'])
|
||||
->whereExists(function ($q) use ($s) {
|
||||
$q->selectRaw('1')->from('permission_grants as g')->whereColumn('g.user_id', 'u.id')->where('g.tenant_id', $s['tenant_id'])->where('g.company_id', $s['company_id'])->where('g.unit_id', $s['unit_id'])->where('g.permission', 'unit.view');
|
||||
})->get(['u.id', 'u.name'])->all()];
|
||||
}
|
||||
|
||||
public function authorize(string $action, ?array $record): void
|
||||
{
|
||||
abort_unless($this->has('view'), 403, 'Consulta não autorizada.');
|
||||
if ($action !== 'configure') {
|
||||
abort_unless($this->setup()['enabled'], 403, 'Opção interna não configurada para o produto.');
|
||||
}
|
||||
if ($record !== null) {
|
||||
abort_unless($record['work_context'] === 'procurement.internal', 403);
|
||||
$all = $this->has('configure') || $this->has('approve.procurement') || $this->has('approve.finance');
|
||||
$visible = $all || $record['requester_id'] === $this->actor->id || ($this->has('approve.department_head') && $record['department_ref'] === $this->department());
|
||||
abort_unless($visible, 404);
|
||||
}
|
||||
if (in_array($action, ['save', 'submit'], true)) {
|
||||
abort_unless($this->has('prepare') && $this->department() !== null, 403);
|
||||
if ($record !== null) {
|
||||
abort_unless($record['requester_id'] === $this->actor->id, 403);
|
||||
}
|
||||
} elseif ($action === 'configure') {
|
||||
abort_unless($this->has('configure'), 403);
|
||||
} elseif ($action === 'decide') {
|
||||
abort_unless($this->has('approve.department_head') || $this->has('approve.procurement') || $this->has('approve.finance'), 403);
|
||||
} elseif (str_starts_with($action, 'step:')) {
|
||||
$role = substr($action, 5);
|
||||
abort_unless($this->has('approve.'.$role) && ($role !== 'department_head' || $record['department_ref'] === $this->department()), 403);
|
||||
}
|
||||
}
|
||||
|
||||
public function load(int $id): array
|
||||
{
|
||||
$row = $this->query('requests')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($row, 404);
|
||||
$r = (array) $row;
|
||||
foreach (['data', 'approval_snapshot', 'reservation'] as $field) {
|
||||
$r[$field] = $r[$field] === null ? null : json_decode($r[$field], true, 64, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
foreach (['id', 'requester_id', 'version', 'subtotal_minor', 'tax_minor', 'total_minor', 'cost_center_id'] as $field) {
|
||||
$r[$field] = (int) $r[$field];
|
||||
}
|
||||
$r['steps'] = $this->query('steps')->where('request_id', $id)->orderBy('position')->get()->map(fn ($s) => (array) $s)->all();
|
||||
$r['has_equipment'] = count(array_filter($r['data']['items'], fn ($i) => $i['purchase_type'] === 'equipment')) > 0;
|
||||
|
||||
return $r;
|
||||
}
|
||||
|
||||
public function draft(array $input, ?int $id): array
|
||||
{
|
||||
$data = Validator::make($input, [
|
||||
'cost_center_id' => 'required|integer|min:1', 'priority' => 'required|in:normal,high,urgent', 'needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'justification' => 'required|string|min:10|max:5000',
|
||||
'items' => 'required|array|min:1|max:50', 'items.*' => 'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate', 'items.*.description' => 'required|string|max:255', 'items.*.category' => 'required|string|max:100', 'items.*.purchase_type' => 'required|in:consumable,service,other',
|
||||
'items.*.unit' => 'required|string|max:30', 'items.*.quantity' => ['required', 'regex:/^\d{1,7}(\.\d{1,3})?$/D', 'numeric', 'min:0.001', 'max:1000000'],
|
||||
'items.*.unit_price' => ['required', 'regex:/^\d{1,9}(\.\d{1,2})?$/D', 'numeric', 'min:0.01', 'max:100000000'],
|
||||
'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/D', 'numeric', 'between:0,100'],
|
||||
])->validate();
|
||||
abort_if(array_diff(array_keys($input), array_keys($data)) !== [], 422, 'Campo protegido.');
|
||||
$department = $this->department();
|
||||
$mapped = false;
|
||||
foreach ($this->setup()['centres'] as $centre) {
|
||||
if ($centre['id'] === (int) $data['cost_center_id'] && $centre['department_ref'] === $department) {
|
||||
$mapped = true;
|
||||
}
|
||||
}
|
||||
abort_unless($mapped && DB::table('cb_centres')->where($this->scope)->where('id', $data['cost_center_id'])->exists(), 403, 'Centro fora do cadastro autorizado.');
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
foreach ($data['items'] as &$line) {
|
||||
$line = [...$line, ...InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate'])];
|
||||
$subtotal += $line['subtotal_minor'];
|
||||
$tax += $line['tax_minor'];
|
||||
}
|
||||
unset($line);
|
||||
abort_if($subtotal + $tax > 100000000000000, 422, 'Total excede o limite suportado.');
|
||||
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
if ($id === null) {
|
||||
$old = $this->query('requests')->where('origin_key', $this->originKey)->first();
|
||||
if ($old) {
|
||||
abort_unless((int) $old->requester_id === $this->actor->id && hash_equals($old->creation_hash, $hash), 409, 'Origem já registada com outros dados.');
|
||||
|
||||
return $this->load($old->id);
|
||||
}
|
||||
}
|
||||
$values = ['cost_center_id' => $data['cost_center_id'], 'department_ref' => $department, 'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()];
|
||||
if ($id === null) {
|
||||
$id = DB::table('ir_requests')->insertGetId($this->scope + $values + ['requester_id' => $this->actor->id, 'number' => 'IR-'.now()->year.'-'.$this->originKey, 'work_context' => 'procurement.internal', 'origin_key' => $this->originKey, 'creation_hash' => $hash, 'created_at' => now()]);
|
||||
} else {
|
||||
$old = $this->load($id);
|
||||
$this->query('requests')->where('id', $id)->update($values + ['version' => $old['version'] + 1]);
|
||||
}
|
||||
|
||||
return $this->event($id, 'draft.saved');
|
||||
}
|
||||
|
||||
public function policy(array $record): array
|
||||
{
|
||||
$policy = $this->setup();
|
||||
abort_unless($this->department() === $record['department_ref'], 422, 'A atribuição ao departamento mudou; reveja o rascunho.');
|
||||
abort_unless($record['data']['needed_by'] >= today()->toDateString(), 422, 'Actualize a data pretendida.');
|
||||
$mapped = false;
|
||||
foreach ($policy['centres'] as $c) {
|
||||
if ($c['id'] === $record['cost_center_id'] && $c['department_ref'] === $record['department_ref']) {
|
||||
$mapped = true;
|
||||
}
|
||||
}
|
||||
abort_unless($mapped, 422, 'A associação do centro mudou; reveja o rascunho.');
|
||||
|
||||
return array_intersect_key($policy, array_flip(['revision', 'first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional', 'budget_required']));
|
||||
}
|
||||
|
||||
public function reserve(array $record): ?array
|
||||
{
|
||||
$origin = DB::table('cb_origins')->where($this->scope)->where('reference', 'internal-requisition:'.$record['origin_key'])->first();
|
||||
$id = $origin?->id ?? DB::table('cb_origins')->insertGetId($this->scope + ['cost_center_id' => $record['cost_center_id'], 'required_kind' => 'operating', 'reference' => 'internal-requisition:'.$record['origin_key']]);
|
||||
$result = app(BudgetAdapter::class)->record($this->actor, (int) $id, 'reserve', $record['total_minor'], $record['subtotal_minor']);
|
||||
abort_if($result === null && $this->setup()['budget_required'], 422, 'Dotação não configurada; nenhuma submissão ou reserva foi gravada.');
|
||||
// Persisted atomically with submission; no intermediate root mutation/version.
|
||||
$this->pendingReservation = ['origin_id' => (int) $id, 'result' => $result];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private array $pendingReservation = [];
|
||||
|
||||
public function submitted(array $record, array $policy, array $roles): array
|
||||
{
|
||||
foreach ($roles as $i => $role) {
|
||||
DB::table('ir_steps')->insert($this->scope + ['request_id' => $record['id'], 'position' => $i + 1, 'role' => $role]);
|
||||
}
|
||||
$this->query('requests')->where('id', $record['id'])->update(['status' => 'pending', 'version' => $record['version'] + 1, 'approval_snapshot' => json_encode($policy, JSON_THROW_ON_ERROR), 'budget_origin_id' => $this->pendingReservation['origin_id'], 'reservation' => json_encode($this->pendingReservation['result'], JSON_THROW_ON_ERROR), 'updated_at' => now()]);
|
||||
|
||||
return $this->event($record['id'], 'submitted');
|
||||
}
|
||||
|
||||
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array
|
||||
{
|
||||
$this->query('steps')->where('id', $step['id'])->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$this->query('requests')->where('id', $record['id'])->update(['status' => $status, 'version' => $record['version'] + 1, 'updated_at' => now()]);
|
||||
|
||||
return $this->event($record['id'], 'approval.'.$decision);
|
||||
}
|
||||
|
||||
public function release(array $record): void
|
||||
{
|
||||
app(BudgetAdapter::class)->record($this->actor, (int) $record['budget_origin_id'], 'release');
|
||||
}
|
||||
|
||||
private function event(int $id, string $action): array
|
||||
{
|
||||
$record = $this->load($id);
|
||||
DB::table('ir_events')->insert($this->scope + ['request_id' => $id, 'version' => $record['version'], 'actor_id' => $this->actor->id, 'action' => $action, 'payload' => json_encode($record, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function replay(string $key, string $hash): ?array
|
||||
{
|
||||
$old = $this->query('commands')->where(['actor_id' => $this->actor->id, 'operation_key' => $key])->first();
|
||||
if (! $old) {
|
||||
return null;
|
||||
}
|
||||
abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.');
|
||||
|
||||
return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function remember(string $key, string $hash, array $result): void
|
||||
{
|
||||
DB::table('ir_commands')->insert($this->scope + ['actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Purchasing\SgfCandidate;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
/** Host-owned additive schema, deliberately outside automatic package installation. */
|
||||
final class Schema
|
||||
{
|
||||
public static function install(ConnectionInterface $db): void
|
||||
{
|
||||
$schema = $db->getSchemaBuilder();
|
||||
if ($schema->hasTable('ir_requests')) {
|
||||
throw new \RuntimeException('Schema já existe; não redefinir dados.');
|
||||
}
|
||||
$db->transaction(function () use ($schema, $db) {
|
||||
if ($db->getDriverName() === 'pgsql') {
|
||||
$db->statement('ALTER TABLE permission_grants ALTER COLUMN permission TYPE varchar(100)');
|
||||
$check = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
|
||||
if (! $check || ! preg_match('/^CHECK\s*\((.*)\)$/s', $check->definition, $parts)) {
|
||||
throw new \RuntimeException('Conferir o catálogo de permissões antes de migrar.');
|
||||
}
|
||||
$db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ('purchasing_requisitions.view'::text,'purchasing_requisitions.prepare'::text,'purchasing_requisitions.configure'::text,'purchasing_requisitions.approve.department_head'::text,'purchasing_requisitions.approve.procurement'::text,'purchasing_requisitions.approve.finance'::text)))");
|
||||
}
|
||||
$schema->create('ir_requests', function (Blueprint $t) {
|
||||
$t->id();
|
||||
foreach (['tenant_id', 'company_id', 'unit_id', 'requester_id', 'cost_center_id'] as $c) {
|
||||
$t->unsignedBigInteger($c);
|
||||
}
|
||||
$t->string('work_context', 40)->default('procurement.internal');
|
||||
$t->uuid('origin_key');
|
||||
$t->string('creation_hash', 64);
|
||||
$t->string('number')->nullable();
|
||||
$t->string('department_ref', 100);
|
||||
$t->string('status')->default('draft');
|
||||
$t->unsignedInteger('version')->default(1);
|
||||
$t->bigInteger('subtotal_minor');
|
||||
$t->bigInteger('tax_minor');
|
||||
$t->bigInteger('total_minor');
|
||||
$t->jsonb('data');
|
||||
$t->jsonb('approval_snapshot')->nullable();
|
||||
$t->unsignedBigInteger('budget_origin_id')->nullable();
|
||||
$t->jsonb('reservation')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'origin_key']);
|
||||
$t->unique(['id', 'tenant_id', 'company_id', 'unit_id']);
|
||||
});
|
||||
foreach (['steps', 'events', 'commands', 'policies', 'policy_versions'] as $kind) {
|
||||
$schema->create('ir_'.$kind, function (Blueprint $t) use ($kind) {
|
||||
$t->id();
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $c) {
|
||||
$t->unsignedBigInteger($c);
|
||||
}
|
||||
if (in_array($kind, ['steps', 'events'], true)) {
|
||||
$t->unsignedBigInteger('request_id');
|
||||
$t->foreign(['request_id', 'tenant_id', 'company_id', 'unit_id'])->references(['id', 'tenant_id', 'company_id', 'unit_id'])->on('ir_requests')->restrictOnDelete();
|
||||
}
|
||||
if ($kind === 'steps') {
|
||||
$t->unsignedInteger('position');
|
||||
$t->string('role');
|
||||
$t->string('decision')->default('pending');
|
||||
$t->unsignedBigInteger('approver_id')->nullable();
|
||||
$t->text('comment')->nullable();
|
||||
$t->timestamp('decided_at')->nullable();
|
||||
$t->unique(['request_id', 'position']);
|
||||
$t->unique(['request_id', 'approver_id']);
|
||||
} elseif ($kind === 'commands') {
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->uuid('operation_key');
|
||||
$t->string('request_hash', 64);
|
||||
$t->jsonb('result');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'operation_key']);
|
||||
} else {
|
||||
$t->unsignedInteger('version');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->jsonb('payload');
|
||||
$t->timestamp('created_at');
|
||||
if ($kind === 'events') {
|
||||
$t->string('action');
|
||||
$t->unique(['request_id', 'version']);
|
||||
} else {
|
||||
$t->unique($kind === 'policies' ? ['tenant_id', 'company_id', 'unit_id'] : ['tenant_id', 'company_id', 'unit_id', 'version']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($db->getDriverName() === 'pgsql') {
|
||||
$db->statement("ALTER TABLE ir_requests ADD CONSTRAINT ir_internal_context CHECK (work_context='procurement.internal')");
|
||||
foreach (['ir_events', 'ir_commands', 'ir_policy_versions'] as $table) {
|
||||
$db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Append-only history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();");
|
||||
}
|
||||
$db->unprepared(<<<'SQL'
|
||||
CREATE FUNCTION ir_origin_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve requisition origin'; END IF;
|
||||
IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.work_context,NEW.origin_key,NEW.requester_id,NEW.creation_hash) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.work_context,OLD.origin_key,OLD.requester_id,OLD.creation_hash) THEN RAISE EXCEPTION 'Immutable origin'; END IF;
|
||||
IF OLD.status <> 'draft' AND (NEW.data,NEW.subtotal_minor,NEW.tax_minor,NEW.total_minor,NEW.cost_center_id,NEW.department_ref,NEW.approval_snapshot,NEW.budget_origin_id,NEW.reservation) IS DISTINCT FROM (OLD.data,OLD.subtotal_minor,OLD.tax_minor,OLD.total_minor,OLD.cost_center_id,OLD.department_ref,OLD.approval_snapshot,OLD.budget_origin_id,OLD.reservation) THEN RAISE EXCEPTION 'Preserve submitted snapshot'; END IF;
|
||||
IF NEW.version <= OLD.version THEN RAISE EXCEPTION 'Version must advance'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ir_origin_guard BEFORE UPDATE OR DELETE ON ir_requests FOR EACH ROW EXECUTE FUNCTION ir_origin_guard();
|
||||
CREATE FUNCTION ir_step_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Preserve approval steps'; END IF;
|
||||
IF OLD.decision <> 'pending' THEN RAISE EXCEPTION 'Final decision immutable'; END IF;
|
||||
IF NEW.approver_id = (SELECT requester_id FROM ir_requests WHERE id=NEW.request_id) THEN RAISE EXCEPTION 'Independent identity required'; END IF;
|
||||
IF (NEW.request_id,NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.position,NEW.role) IS DISTINCT FROM (OLD.request_id,OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.position,OLD.role) THEN RAISE EXCEPTION 'Step origin immutable'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ir_step_guard BEFORE UPDATE OR DELETE ON ir_steps FOR EACH ROW EXECUTE FUNCTION ir_step_guard();
|
||||
SQL);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// Explicit candidate entry point. Not referenced by the normal SGF provider/routes.
|
||||
foreach (['RequisitionPort', 'RequisitionFailure', 'InternalRequisitions'] as $class) {
|
||||
require_once dirname(__DIR__, 3).'/packages/purchasing/src/'.$class.'.php';
|
||||
}
|
||||
foreach (['Schema', 'Port', 'Controller'] as $class) {
|
||||
require_once __DIR__.'/'.$class.'.php';
|
||||
}
|
||||
config(['purchasing-internal.candidate_enabled' => true]);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tempo\Purchasing\SgfCandidate\Controller;
|
||||
|
||||
// Required only by the isolated candidate harness; never by normal destination routes.
|
||||
Route::middleware(['web', 'auth', RequireValidSession::class, RequireCompanyContext::class])->group(function () {
|
||||
Route::get('/purchasing/internal-requisitions', [Controller::class, 'page']);
|
||||
Route::get('/purchasing/internal-requisitions/screen.mjs', [Controller::class, 'script']);
|
||||
Route::get('/api/internal-requisitions', [Controller::class, 'index']);
|
||||
Route::get('/api/internal-requisitions/{id}', [Controller::class, 'show'])->whereNumber('id');
|
||||
Route::post('/api/internal-requisitions/policy', [Controller::class, 'configure']);
|
||||
Route::post('/api/internal-requisitions/save', [Controller::class, 'command'])->defaults('action', 'save');
|
||||
Route::post('/api/internal-requisitions/{id}/{action}', fn (Request $r, int $id, string $action) => (new Controller)->command($r, $action, $id))->whereNumber('id')->whereIn('action', ['save', 'submit', 'decide']);
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
export function actionsFor(record, actor, permissions) {
|
||||
if (Array.isArray(record.allowed_actions)) return record.allowed_actions;
|
||||
const own = record.requester_id === actor;
|
||||
if (record.status === 'draft')
|
||||
return own && permissions.prepare ? ['edit', 'submit'] : [];
|
||||
if (
|
||||
record.status !== 'pending' ||
|
||||
own ||
|
||||
record.steps.some((step) => step.approver_id === actor)
|
||||
)
|
||||
return [];
|
||||
const next = record.steps.find((step) => step.decision === 'pending');
|
||||
return next && permissions[next.role] ? ['approved', 'rejected'] : [];
|
||||
}
|
||||
export function commandEnvelope(key, version, data = {}) {
|
||||
return {
|
||||
...data,
|
||||
operation_key: key,
|
||||
...(version == null ? {} : { version }),
|
||||
};
|
||||
}
|
||||
export function limitMinor(value) {
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(value))
|
||||
throw new Error('Indique o limite em MZN com até duas casas decimais.');
|
||||
const [major, fraction = ''] = value.split('.');
|
||||
const minor = BigInt(major) * 100n + BigInt(fraction.padEnd(2, '0'));
|
||||
if (minor > 100000000000000n)
|
||||
throw new Error('Limite superior ao montante suportado.');
|
||||
return Number(minor);
|
||||
}
|
||||
export function limitMajor(minor) {
|
||||
const value = BigInt(minor);
|
||||
return `${value / 100n}.${String(value % 100n).padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
const contract = JSON.parse(
|
||||
document.querySelector('#ir-contract').textContent,
|
||||
);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (tag, text, parent) => {
|
||||
const node = document.createElement(tag);
|
||||
if (text != null) node.textContent = text;
|
||||
if (parent) parent.append(node);
|
||||
return node;
|
||||
};
|
||||
let state,
|
||||
selected,
|
||||
draft,
|
||||
dirty = false,
|
||||
busy = false;
|
||||
const keys = new Map();
|
||||
const money = (minor) =>
|
||||
new Intl.NumberFormat('pt-MZ', {
|
||||
style: 'currency',
|
||||
currency: 'MZN',
|
||||
}).format(minor / 100);
|
||||
function notice(text) {
|
||||
$('notice').textContent = text;
|
||||
$('notice').className = text ? 'notice' : '';
|
||||
}
|
||||
async function api(path, method = 'GET', data) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': contract.csrf,
|
||||
'X-SGF-Context': contract.token,
|
||||
'X-Purchasing-Context': contract.context,
|
||||
},
|
||||
...(data ? { body: JSON.stringify(data) } : {}),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
Object.values(result.errors || {})
|
||||
.flat()
|
||||
.join(' ') ||
|
||||
result.message ||
|
||||
'Operação não concluída. O contexto e a chave de repetição foram mantidos.',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function perform(work) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
notice('A processar…');
|
||||
document.querySelectorAll('button').forEach((b) => (b.disabled = true));
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
notice(error.message);
|
||||
} finally {
|
||||
busy = false;
|
||||
document
|
||||
.querySelectorAll('button')
|
||||
.forEach((b) => (b.disabled = false));
|
||||
}
|
||||
}
|
||||
async function command(action, record, data = {}) {
|
||||
const slot = `${action}:${record?.id ?? 'new'}`;
|
||||
if (!keys.has(slot)) keys.set(slot, crypto.randomUUID());
|
||||
const result = await api(
|
||||
`/api/internal-requisitions/${record ? `${record.id}/${action}` : 'save'}`,
|
||||
'POST',
|
||||
commandEnvelope(keys.get(slot), record?.version, data),
|
||||
);
|
||||
keys.delete(slot);
|
||||
dirty = false;
|
||||
selected = result;
|
||||
if (action === 'save') draft = result;
|
||||
try {
|
||||
await load();
|
||||
await detail(result.id);
|
||||
notice(
|
||||
result.replayed
|
||||
? 'Resposta recuperada sem repetir efeitos.'
|
||||
: 'Operação registada com origem e versão.',
|
||||
);
|
||||
} catch {
|
||||
notice(
|
||||
'Operação confirmada. Não foi possível actualizar a consulta; use Actualizar. A origem gravada foi mantida.',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function line(data = {}) {
|
||||
const node = el('div', null, $('lines'));
|
||||
node.className = 'line';
|
||||
for (const [name, label, value] of [
|
||||
['description', 'Descrição', ''],
|
||||
['category', 'Categoria', ''],
|
||||
['quantity', 'Quantidade', '1.000'],
|
||||
['unit', 'Unidade', 'UN'],
|
||||
['unit_price', 'Preço unitário MZN', ''],
|
||||
['tax_rate', 'IVA %', ''],
|
||||
]) {
|
||||
const labelNode = el('label', label, node),
|
||||
input = el('input', null, labelNode);
|
||||
input.name = name;
|
||||
input.value = data[name] ?? value;
|
||||
input.required = true;
|
||||
if (['quantity', 'unit_price', 'tax_rate'].includes(name))
|
||||
input.inputMode = 'decimal';
|
||||
}
|
||||
const label = el('label', 'Tipo', node),
|
||||
type = el('select', null, label);
|
||||
type.name = 'purchase_type';
|
||||
for (const [value, text] of [
|
||||
['consumable', 'Consumível'],
|
||||
['service', 'Serviço'],
|
||||
['other', 'Outro'],
|
||||
]) {
|
||||
const option = el('option', text, type);
|
||||
option.value = value;
|
||||
}
|
||||
type.value = data.purchase_type || 'consumable';
|
||||
const remove = el('button', 'Remover', node);
|
||||
remove.type = 'button';
|
||||
remove.onclick = () => {
|
||||
node.remove();
|
||||
dirty = true;
|
||||
};
|
||||
}
|
||||
function edit(record) {
|
||||
if (dirty && !confirm('Descartar as alterações por guardar?')) return;
|
||||
draft = record;
|
||||
$('draft-panel').hidden = false;
|
||||
$('draft-title').textContent = record
|
||||
? `Editar ${record.number}`
|
||||
: 'Nova requisição interna';
|
||||
$('lines').replaceChildren();
|
||||
const form = $('draft');
|
||||
for (const name of [
|
||||
'cost_center_id',
|
||||
'priority',
|
||||
'needed_by',
|
||||
'justification',
|
||||
])
|
||||
form.elements[name].value =
|
||||
record?.data[name] ?? (name === 'priority' ? 'normal' : '');
|
||||
(record?.data.items ?? [{}]).forEach(line);
|
||||
dirty = false;
|
||||
}
|
||||
async function detail(id) {
|
||||
if (dirty && !confirm('Descartar as alterações por guardar?')) return;
|
||||
const result = await api(`/api/internal-requisitions/${id}`);
|
||||
selected = result.record;
|
||||
$('detail').hidden = false;
|
||||
$('detail-title').textContent =
|
||||
`${selected.number} · ${selected.status} · Versão ${selected.version}`;
|
||||
$('detail-data').replaceChildren();
|
||||
el('p', selected.data.justification, $('detail-data'));
|
||||
el(
|
||||
'p',
|
||||
`Total: ${money(selected.total_minor)} · Origem: ${selected.origin_key}`,
|
||||
$('detail-data'),
|
||||
);
|
||||
if (selected.status !== 'draft')
|
||||
el(
|
||||
'p',
|
||||
selected.reservation
|
||||
? 'Reserva orçamental registada.'
|
||||
: 'Sem reserva: política aplicada não a exigia e não havia dotação configurada.',
|
||||
$('detail-data'),
|
||||
);
|
||||
$('steps').replaceChildren();
|
||||
selected.steps.forEach((step) =>
|
||||
el(
|
||||
'p',
|
||||
`${step.position}. ${step.role} · ${step.decision}${step.approver_id ? ` · Identidade ${step.approver_id}` : ''}`,
|
||||
$('steps'),
|
||||
),
|
||||
);
|
||||
$('history').replaceChildren();
|
||||
result.history.forEach((event) =>
|
||||
el(
|
||||
'p',
|
||||
`Versão ${event.version} · ${event.action} · ${event.created_at}`,
|
||||
$('history'),
|
||||
),
|
||||
);
|
||||
$('actions').replaceChildren();
|
||||
for (const action of actionsFor(
|
||||
selected,
|
||||
contract.actor_id,
|
||||
state.permissions,
|
||||
)) {
|
||||
const button = el(
|
||||
'button',
|
||||
{
|
||||
edit: 'Editar',
|
||||
submit: 'Submeter',
|
||||
approved: 'Aprovar etapa',
|
||||
rejected: 'Rejeitar',
|
||||
}[action],
|
||||
$('actions'),
|
||||
);
|
||||
button.onclick = () =>
|
||||
action === 'edit'
|
||||
? edit(selected)
|
||||
: perform(async () => {
|
||||
const comment =
|
||||
action === 'rejected'
|
||||
? prompt('Fundamente a rejeição')
|
||||
: null;
|
||||
if (action === 'rejected' && !comment) return;
|
||||
await command(
|
||||
action === 'approved' || action === 'rejected'
|
||||
? 'decide'
|
||||
: action,
|
||||
selected,
|
||||
action === 'submit'
|
||||
? {}
|
||||
: { decision: action, comment },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
function mapping(parent, data, idField) {
|
||||
const row = el('div', null, $(parent));
|
||||
row.className = 'line';
|
||||
const label = el(
|
||||
'label',
|
||||
idField === 'id' ? 'Centro de custo' : 'Pessoa',
|
||||
row,
|
||||
),
|
||||
select = el('select', null, label);
|
||||
select.name = idField;
|
||||
select.required = true;
|
||||
const empty = el('option', 'Seleccione…', select);
|
||||
empty.value = '';
|
||||
const sources =
|
||||
state.configurationSources?.[
|
||||
idField === 'id' ? 'centres' : 'members'
|
||||
] ?? [];
|
||||
for (const source of sources) {
|
||||
const option = el('option', source.code ?? source.name, select);
|
||||
option.value = source.id;
|
||||
}
|
||||
select.value = data?.[idField] ?? '';
|
||||
const departmentLabel = el('label', 'Departamento', row),
|
||||
input = el('input', null, departmentLabel);
|
||||
input.name = 'department_ref';
|
||||
input.value = data?.department_ref ?? '';
|
||||
input.required = true;
|
||||
const b = el('button', 'Retirar associação', row);
|
||||
b.type = 'button';
|
||||
b.onclick = () => {
|
||||
row.remove();
|
||||
dirty = true;
|
||||
};
|
||||
}
|
||||
async function load() {
|
||||
state = await api('/api/internal-requisitions');
|
||||
$('new').hidden = !state.permissions.prepare;
|
||||
$('policy-panel').hidden = !state.permissions.configure;
|
||||
const select = $('draft').elements.cost_center_id;
|
||||
select.replaceChildren();
|
||||
(state.centres || []).forEach((c) => {
|
||||
const o = el('option', `${c.label} · ${c.department_ref}`, select);
|
||||
o.value = c.id;
|
||||
});
|
||||
$('records').replaceChildren();
|
||||
if (!state.records.length)
|
||||
el('p', 'Sem pedidos autorizados neste contexto.', $('records'));
|
||||
state.records.forEach((r) => {
|
||||
const b = el(
|
||||
'button',
|
||||
`${r.number} · ${r.status} · ${money(r.total_minor)}`,
|
||||
$('records'),
|
||||
);
|
||||
b.onclick = () => perform(() => detail(r.id));
|
||||
});
|
||||
if (state.permissions.configure) {
|
||||
const p = state.policy,
|
||||
form = $('policy');
|
||||
for (const k of [
|
||||
'enabled',
|
||||
'budget_required',
|
||||
'approval_includes_tax',
|
||||
])
|
||||
form.elements[k].checked = p?.[k] ?? false;
|
||||
for (const k of ['first_limit_minor', 'second_limit_minor'])
|
||||
form.elements[k].value = p ? limitMajor(p[k]) : '';
|
||||
$('policy-centres').replaceChildren();
|
||||
$('policy-members').replaceChildren();
|
||||
(p?.centres || []).forEach((c) =>
|
||||
mapping('policy-centres', c, 'id'),
|
||||
);
|
||||
(p?.members || []).forEach((m) =>
|
||||
mapping('policy-members', m, 'user_id'),
|
||||
);
|
||||
}
|
||||
}
|
||||
$('new').onclick = () => edit(null);
|
||||
$('reload').onclick = () => perform(load);
|
||||
$('add-line').onclick = () => {
|
||||
line();
|
||||
dirty = true;
|
||||
};
|
||||
$('draft').oninput = () => (dirty = true);
|
||||
$('cancel-draft').onclick = () => {
|
||||
if (!dirty || confirm('Descartar as alterações?')) {
|
||||
$('draft-panel').hidden = true;
|
||||
dirty = false;
|
||||
}
|
||||
};
|
||||
$('draft').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
void perform(async () => {
|
||||
const form = event.target,
|
||||
data = {};
|
||||
for (const k of [
|
||||
'cost_center_id',
|
||||
'priority',
|
||||
'needed_by',
|
||||
'justification',
|
||||
])
|
||||
data[k] =
|
||||
k === 'cost_center_id'
|
||||
? Number(form.elements[k].value)
|
||||
: form.elements[k].value;
|
||||
data.items = [...$('lines').children].map((row) =>
|
||||
Object.fromEntries(
|
||||
[...row.querySelectorAll('input,select')].map((i) => [
|
||||
i.name,
|
||||
i.value,
|
||||
]),
|
||||
),
|
||||
);
|
||||
await command('save', draft, data);
|
||||
$('draft-panel').hidden = true;
|
||||
});
|
||||
};
|
||||
$('add-centre').onclick = () => {
|
||||
mapping('policy-centres', null, 'id');
|
||||
dirty = true;
|
||||
};
|
||||
$('add-member').onclick = () => {
|
||||
mapping('policy-members', null, 'user_id');
|
||||
dirty = true;
|
||||
};
|
||||
$('policy').oninput = () => (dirty = true);
|
||||
$('policy').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
void perform(async () => {
|
||||
const f = event.target,
|
||||
data = {
|
||||
version: state.policy?.revision ?? 0,
|
||||
reason: f.elements.reason.value,
|
||||
};
|
||||
for (const k of [
|
||||
'enabled',
|
||||
'budget_required',
|
||||
'approval_includes_tax',
|
||||
])
|
||||
data[k] = f.elements[k].checked;
|
||||
for (const k of ['first_limit_minor', 'second_limit_minor'])
|
||||
data[k] = limitMinor(f.elements[k].value);
|
||||
for (const [key, parent, id] of [
|
||||
['centres', 'policy-centres', 'id'],
|
||||
['members', 'policy-members', 'user_id'],
|
||||
])
|
||||
data[key] = [...$(parent).children].map((row) =>
|
||||
Object.fromEntries(
|
||||
[...row.querySelectorAll('input,select')].map((i) => [
|
||||
i.name,
|
||||
i.name === id ? Number(i.value) : i.value,
|
||||
]),
|
||||
),
|
||||
);
|
||||
await api('/api/internal-requisitions/policy', 'POST', data);
|
||||
dirty = false;
|
||||
await load();
|
||||
notice(
|
||||
'Parametrização versionada e registada. Permissões não foram alteradas.',
|
||||
);
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (dirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
void perform(load);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Requisições internas · SGF</title>
|
||||
<style>body{font:16px system-ui;margin:0;background:#f4f7f6;color:#15251e}header{background:#14251e;color:white;padding:24px}main{max-width:1120px;margin:auto;padding:24px}section{background:white;padding:24px;margin:20px 0;border:1px solid #d5dfda;border-radius:10px}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;border:1px solid #80988b;border-radius:5px;box-sizing:border-box}input,textarea,select{max-width:100%}textarea{width:100%;min-height:70px}button{background:#116748;color:white;cursor:pointer;margin:5px}button:disabled{opacity:.5;cursor:wait}table{width:100%;border-collapse:collapse}td,th{padding:9px;text-align:left;border-bottom:1px solid #ddd}.line{display:flex;gap:8px;flex-wrap:wrap;border-bottom:1px solid #ddd;padding:10px 0}.line input{width:135px}.line input:first-child{width:250px}.notice{padding:12px;background:#fff3cf}.hidden,[hidden]{display:none!important}a{color:inherit}pre{white-space:pre-wrap;font:inherit}fieldset{margin:12px 0}small{display:block}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #dc8c00;outline-offset:2px}@media(max-width:600px){main,section{padding:12px}table{font-size:13px}}</style></head>
|
||||
<body><header><strong>SGF · Compras internas</strong><h1>Requisições internas</h1><p><?= htmlspecialchars($contract['company'].' · '.$contract['unit'], ENT_QUOTES, 'UTF-8')?></p><small>Candidata isolada de integração · sem activação na instalação de destino</small></header>
|
||||
<main><p id="notice" role="status" aria-live="polite"></p><section><h2>Pedidos autorizados</h2><button id="reload">Actualizar</button><button id="new" hidden>Nova requisição</button><div id="records"></div></section>
|
||||
<section id="draft-panel" hidden><h2 id="draft-title">Rascunho</h2><form id="draft"><label>Centro de custo <select name="cost_center_id" required></select></label><label>Prioridade <select name="priority"><option value="normal">Normal</option><option value="high">Alta</option><option value="urgent">Urgente</option></select></label><label>Data pretendida <input name="needed_by" type="date" required></label><label>Justificação <textarea name="justification" required minlength="10" maxlength="5000"></textarea></label><h3>Artigos ou serviços</h3><p>Os valores e o IVA são conferidos pelo servidor. Esta candidata cobre consumíveis, serviços e outros; equipamentos/peças exigem os respectivos percursos técnicos.</p><div id="lines"></div><button type="button" id="add-line">Adicionar linha</button><button type="submit">Guardar rascunho</button><button type="button" id="cancel-draft">Cancelar</button></form></section>
|
||||
<section id="detail" hidden><h2 id="detail-title"></h2><div id="detail-data"></div><div id="actions"></div><h3>Etapas de aprovação</h3><div id="steps"></div><h3>Histórico por versão</h3><div id="history"></div></section>
|
||||
<section id="policy-panel" hidden><h2>Parametrização deste percurso</h2><p>Não atribui permissões. Alterações aplicam-se a novas submissões; processos submetidos conservam a política utilizada.</p><form id="policy"><label><input name="enabled" type="checkbox"> Opção Compras internas activada para este percurso</label><label><input name="budget_required" type="checkbox"> Exigir dotação para submeter</label><label>Primeiro limite em MZN <input name="first_limit_minor" inputmode="decimal" required></label><label>Segundo limite em MZN <input name="second_limit_minor" inputmode="decimal" required></label><label><input name="approval_includes_tax" type="checkbox"> Aprovação inclui IVA</label><h3>Centros e departamentos autorizados</h3><p>Seleccione os centros e as pessoas dos cadastros autorizados da unidade.</p><div id="policy-centres"></div><button id="add-centre" type="button">Associar centro</button><h3>Pessoas e departamentos</h3><div id="policy-members"></div><button id="add-member" type="button">Associar pessoa</button><label>Motivo <textarea name="reason" required minlength="10"></textarea></label><button type="submit">Guardar parametrização</button></form></section></main>
|
||||
<script type="application/json" id="ir-contract"><?= json_encode($contract, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR)?></script>
|
||||
<script type="module" src="/purchasing/internal-requisitions/screen.mjs"></script></body></html>
|
||||
Reference in New Issue
Block a user