commit4
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class BudgetController extends Controller
|
||||
{
|
||||
public function index(Request $request, BudgetWorkspace $workspace)
|
||||
{
|
||||
return response()->json($workspace->listing($request->user()))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function command(Request $request, string $action, BudgetWorkspace $workspace)
|
||||
{
|
||||
return response()->json($workspace->command($request->user(), $action, $request->all(), (string) $request->header('Idempotency-Key')))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tempo\Finance\Budget\BudgetFailure;
|
||||
|
||||
/** Synthetic UI/application orchestration only. All financial effects delegate to BudgetControl. */
|
||||
final class BudgetWorkspace
|
||||
{
|
||||
private function scope(User $user, string $right): array
|
||||
{
|
||||
BudgetAdapter::guard();
|
||||
$context = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $user->id && $context->userId() === $user->id && $context->unitId() !== null, 403);
|
||||
$s = $context->identifiers() + ['unit_id' => $context->unitId()];
|
||||
$context->selectFor($user->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
|
||||
foreach (array_unique(['view', $right]) as $permission) {
|
||||
app(Permissions::class)->require($user->id, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$permission, $s['unit_id']);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function input(array $data, array $rules): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($data), array_keys($rules)) !== [], 422, 'Campo protegido ou fora do percurso sintético.');
|
||||
|
||||
return Validator::make($data, $rules)->validate();
|
||||
}
|
||||
|
||||
private function money(string $value): int
|
||||
{
|
||||
if (! preg_match('/^(0|[1-9][0-9]{0,11})(\.[0-9]{1,2})?$/D', $value)) {
|
||||
throw ValidationException::withMessages(['amount' => 'Use um montante MZN com ponto e até duas casas, sem arredondamento.']);
|
||||
}
|
||||
|
||||
return BigDecimal::of($value)->multipliedBy(100)->toInt();
|
||||
}
|
||||
|
||||
public function listing(User $user): array
|
||||
{
|
||||
$s = $this->scope($user, 'view');
|
||||
$engine = app(BudgetAdapter::class)->for($user);
|
||||
$budgets = DB::table('cb_budgets')->where($s)->orderBy('id')->get()->map(fn ($b) => (array) $b + ['balance' => $engine->balance($b->id)])->all();
|
||||
$names = 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'])->pluck('u.name', 'u.id')->all();
|
||||
|
||||
return ['synthetic_only' => true, 'currency' => 'MZN', 'current_year' => $engine->context->year, 'budgets' => $budgets,
|
||||
'centres' => DB::table('cb_centres')->where($s)->orderBy('id')->get(),
|
||||
'origins' => DB::table('cb_origins')->where($s)->orderBy('id')->get(),
|
||||
'entries' => DB::table('cb_budget_entries')->where($s)->orderBy('id')->get(),
|
||||
'consumptions' => DB::table('cb_budget_consumptions')->where($s)->orderBy('id')->get(),
|
||||
'consumption_sources' => DB::table('cb_consumption_sources')->where($s)->orderBy('id')->get(),
|
||||
'reinforcements' => DB::table('cb_budget_reinforcements')->where($s)->orderBy('id')->get(['id', 'budget_id', 'created_by', 'reviewed_by', 'reference', 'amount_minor', 'reason', 'status', 'review_reason']),
|
||||
'history' => DB::table('cb_audit')->where($s)->orderBy('id')->get(), 'actors' => $names];
|
||||
}
|
||||
|
||||
public function command(User $user, string $action, array $input, string $key): array
|
||||
{
|
||||
$right = match ($action) {
|
||||
'create' => 'create', 'approve' => 'approve', 'movement' => 'record', 'consume' => 'consume',
|
||||
'reinforcement' => 'reinforcement.propose', 'review' => 'reinforcement.review', default => abort(404),
|
||||
};
|
||||
$s = $this->scope($user, $right);
|
||||
abort_unless(Str::isUuid($key), 422, 'Chave de comando em falta.');
|
||||
ksort($input);
|
||||
$hash = hash('sha256', json_encode($input, JSON_THROW_ON_ERROR));
|
||||
try {
|
||||
return DB::transaction(function () use ($user, $action, $right, $input, $key, $hash, $s) {
|
||||
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->scope($user, $right);
|
||||
$commands = DB::table('cb_ui_commands')->where($s)->where('actor_id', $user->id)->where('command_key', $key);
|
||||
if ($old = $commands->first()) {
|
||||
abort_unless($old->action === $action && hash_equals($old->request_hash, $hash), 409, 'Chave já usada com outro conteúdo.');
|
||||
|
||||
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
|
||||
}
|
||||
$adapter = app(BudgetAdapter::class);
|
||||
$engine = $adapter->for($user);
|
||||
$idRule = 'required|integer|min:1';
|
||||
if ($action === 'create') {
|
||||
$d = $this->input($input, ['cost_center_id' => $idRule, 'year' => 'required|integer|min:2026|max:2100', 'kind' => 'required|in:operating,investment', 'includes_tax' => 'required|boolean', 'amount' => 'required|string', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$d['amount_minor'] = $this->money($d['amount']);
|
||||
unset($d['amount']);
|
||||
$d['includes_tax'] = (bool) $d['includes_tax'];
|
||||
$id = $adapter->create($user, $d);
|
||||
} elseif ($action === 'approve') {
|
||||
$d = $this->input($input, ['budget_id' => $idRule]);
|
||||
$id = $d['budget_id'];
|
||||
$engine->approve($id);
|
||||
} elseif ($action === 'reinforcement') {
|
||||
$d = $this->input($input, ['budget_id' => $idRule, 'amount' => 'required|string', 'reference' => 'required|string|max:100', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$id = $engine->proposeReinforcement($d['budget_id'], $this->money($d['amount']), Str::upper(Str::squish($d['reference'])), trim($d['reason']));
|
||||
} elseif ($action === 'review') {
|
||||
$d = $this->input($input, ['reinforcement_id' => $idRule, 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$id = $d['reinforcement_id'];
|
||||
$engine->reviewReinforcement($id, $d['decision'], $d['reason']);
|
||||
} else {
|
||||
$rules = ['origin_id' => $idRule, 'total' => 'required|string', 'subtotal' => 'required|string'];
|
||||
$rules += $action === 'movement' ? ['kind' => 'required|in:reserve,award,commit,release'] : ['reference' => 'required|string|min:3|max:100'];
|
||||
$d = $this->input($input, $rules);
|
||||
$origin = DB::table('cb_origins')->where($s)->where('id', $d['origin_id'])->first() ?? abort(403, 'Origem fora deste contexto.');
|
||||
$total = $this->money($d['total']);
|
||||
$net = $this->money($d['subtotal']);
|
||||
abort_if($net > $total, 422, 'Subtotal não pode exceder o total indicado.');
|
||||
$entries = DB::table('cb_budget_entries')->where($s)->where('requisition_id', $origin->id);
|
||||
if ($action === 'movement') {
|
||||
$kind = $d['kind'];
|
||||
if (! (clone $entries)->where('kind', $kind)->exists()) {
|
||||
// Procurement sequence, not budget arithmetic. No reopening or invented reversal.
|
||||
$reserved = (clone $entries)->where('kind', 'reserve')->exists();
|
||||
$closed = (clone $entries)->whereIn('kind', ['commit', 'release'])->exists();
|
||||
abort_if($closed || ($kind !== 'reserve' && ! $reserved) || ($kind === 'commit' && ! (clone $entries)->where('kind', 'award')->exists()), 409, 'Sequência indisponível: reserve, ajuste a adjudicação e depois comprometa; libertação apenas antes do compromisso.');
|
||||
}
|
||||
$effect = $adapter->record($user, $origin->id, $kind, $total, $net);
|
||||
abort_unless($effect, 409, 'Falta preparar e aprovar uma dotação para este centro/exercício.');
|
||||
$id = $effect['budget_id'];
|
||||
} else {
|
||||
$reference = Str::upper(Str::squish($d['reference']));
|
||||
$sources = DB::table('cb_consumption_sources')->where($s)->where('reference', $reference);
|
||||
if ($source = $sources->first()) {
|
||||
abort_unless((int) $source->origin_id === $origin->id && (int) $source->total_minor === $total && (int) $source->subtotal_minor === $net, 409, 'Referência sintética já usada com outros valores ou origem.');
|
||||
$id = $source->id;
|
||||
} else {
|
||||
$id = DB::table('cb_consumption_sources')->insertGetId($s + ['origin_id' => $origin->id, 'reference' => $reference, 'total_minor' => $total, 'subtotal_minor' => $net, 'created_by' => $user->id, 'created_at' => now()]);
|
||||
}
|
||||
$engine->consume($origin->id, $id, $total, $net);
|
||||
}
|
||||
}
|
||||
$result = ['id' => (int) $id, 'action' => $action];
|
||||
DB::table('cb_ui_commands')->insert($s + ['actor_id' => $user->id, 'command_key' => $key, 'action' => $action, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
|
||||
return $result + ['replayed' => false];
|
||||
});
|
||||
} catch (BudgetFailure $e) {
|
||||
abort_if($e->status !== 422, $e->status, $e->getMessage());
|
||||
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
final class SupplierPayables
|
||||
{
|
||||
public static function guard(): void
|
||||
{
|
||||
$c = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
|
||||
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
|
||||
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
|
||||
}
|
||||
}
|
||||
|
||||
public function scope(User $u): array
|
||||
{
|
||||
self::guard();
|
||||
$c = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $u->id && $c->userId() === $u->id && $c->unitId(), 403);
|
||||
$ids = $c->identifiers();
|
||||
app(Permissions::class)->require($u->id, $ids['tenant_id'], $ids['company_id'], 'supplier_payables.view', $c->unitId());
|
||||
|
||||
return $c->identifiers() + ['unit_id' => $c->unitId()];
|
||||
}
|
||||
|
||||
public function can(User $u, string $a): bool
|
||||
{
|
||||
return DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $a])->exists();
|
||||
}
|
||||
|
||||
public function document(User $u, int $id): array
|
||||
{
|
||||
$row = DB::table('pa_imported_sources')->where($this->scope($u))->where('id', $id)->first() ?? abort(403, 'Origem não mapeada neste contexto.');
|
||||
$s = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($row->receipt_hash, hash('sha256', Payables::canonical($s))), 409, 'Recibo de origem divergente.');
|
||||
$s['local_id'] = $id;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function engine(User $u): Payables
|
||||
{
|
||||
$scope = $this->scope($u);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless($person, 403);
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, function (string $a) use ($u, $scope, $person) {
|
||||
self::guard();
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
|
||||
abort_unless($this->scope($u) === $scope, 409, 'Contexto mudou.');
|
||||
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
abort_unless($this->can($u, 'view') && $this->can($u, $a), 403);
|
||||
}, fn (int $id) => $this->document($u, $id));
|
||||
}
|
||||
|
||||
public function listing(User $u): array
|
||||
{
|
||||
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user()))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user());
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s)
|
||||
{
|
||||
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
|
||||
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
|
||||
try {
|
||||
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user