commit14
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
|
||||
require __DIR__.'/../Support/InboxFixture.php';
|
||||
|
||||
if (getenv('INBOX_TEST_PG') !== '1') {
|
||||
throw new LogicException('Executor limitado à prova PostgreSQL descartável.');
|
||||
}
|
||||
$mode = $argv[1] ?? '';
|
||||
if ($mode === 'prepare') {
|
||||
$db = inboxDatabase();
|
||||
$inbox = new WarehouseInbox($db, 'synthetic-sgc-a', 7, 99, 'reader');
|
||||
$inbox->accept(inboxPage([], 'cursor-start'), 0, null);
|
||||
echo json_encode(['checkpoint' => $inbox->checkpoint()], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
$db = inboxConnection();
|
||||
$inbox = new WarehouseInbox($db, 'synthetic-sgc-a', 7, 99, 'reader');
|
||||
if ($mode === 'hold') {
|
||||
$db->beginTransaction();
|
||||
$db->query('SELECT version FROM sgc_exchange_streams FOR UPDATE')->fetchColumn();
|
||||
echo 'locked';
|
||||
flush();
|
||||
usleep(1500000);
|
||||
$db->commit();
|
||||
}
|
||||
if (! in_array($mode, ['hold', 'run', 'exit-after-commit'], true)) {
|
||||
throw new LogicException('Modo inválido.');
|
||||
}
|
||||
$id = (int) ($argv[2] ?? 1);
|
||||
try {
|
||||
$result = $inbox->accept(inboxPage([inboxEvent($id)], 'cursor-'.$id), 1, 'cursor-start');
|
||||
if ($mode === 'exit-after-commit') {
|
||||
exit(42); // committed, acknowledgement intentionally lost
|
||||
}
|
||||
echo json_encode(['result' => 'accepted', ...$result], JSON_THROW_ON_ERROR);
|
||||
} catch (RuntimeException $e) {
|
||||
echo json_encode(['result' => 'conflict'], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
|
||||
throw new LogicException('Apenas base descartável.');
|
||||
}
|
||||
require_once dirname(__DIR__).'/Support/SuggestionFixture.php';
|
||||
if (($argv[1] ?? '') === 'prepare') {
|
||||
$f = suggestionFixture();
|
||||
app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'suspend', $f['policy']));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'agreement_key' => $f['agreement']->record_key]));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Configuração da prova de recuperação isolada.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(AutomaticPurchases::class)->save($f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação independente da regra de ensaio.', 'data' => []]);
|
||||
// Controlled abrupt exit at the start of the post-commit callback. Enqueue remains real.
|
||||
app()->instance(ReplenishmentQueue::class, new class extends ReplenishmentQueue
|
||||
{
|
||||
public function interactive(User $actor, int $id): bool
|
||||
{
|
||||
$work = DB::table('replenishment_work')->where('id', $id)->sole();
|
||||
echo json_encode(['work' => $work->id, 'tenant' => $work->tenant_id, 'stock' => $work->stock_item_id, 'warehouse' => $work->warehouse_id], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
});
|
||||
app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'issue', 'reference' => 'CRASH-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $f['issuer']->id, 'warehouse_id' => $f['warehouse']->id, 'stock_item_id' => $f['stock']->id, 'quantity' => '120.000', 'destination' => 'Oficina interna', 'reason' => 'Saída confirmada antes da interrupção real do processo.']);
|
||||
throw new RuntimeException('A interrupção devia terminar o processo.');
|
||||
}
|
||||
$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
|
||||
if (($argv[1] ?? '') === 'hold') {
|
||||
$held = false;
|
||||
DB::listen(function (QueryExecuted $query) use (&$held) {
|
||||
if (! $held && str_contains($query->sql, '"tenants"') && str_contains(strtolower($query->sql), 'for update')) {
|
||||
$held = true;
|
||||
echo "locked\n";
|
||||
flush();
|
||||
usleep(2000000);
|
||||
}
|
||||
});
|
||||
}
|
||||
$ok = app(ReplenishmentQueue::class)->scheduled($data['work']);
|
||||
echo json_encode(['result' => $ok ? 'saved' : 'failed', 'authenticated' => auth()->check(), 'drafts' => DB::table('requisitions')->where('tenant_id', $data['tenant'])->where('status', 'draft')->pluck('id')->all()], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
|
||||
throw new LogicException('Apenas base descartável.');
|
||||
}
|
||||
require_once dirname(__DIR__).'/Support/SuggestionFixture.php';
|
||||
if (($argv[1] ?? '') === 'prepare') {
|
||||
$f = suggestionFixture();
|
||||
app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'suspend', $f['policy']));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'agreement_key' => $f['agreement']->record_key]));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'issue', 'reference' => 'EMPTY-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $f['issuer']->id, 'warehouse_id' => $f['warehouse']->id, 'stock_item_id' => $f['stock']->id, 'quantity' => '120.000', 'destination' => 'Oficina interna', 'reason' => 'Consumo efectivo antes da configuração de reposição.']);
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Configuração fictícia da prova concorrente.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
|
||||
echo json_encode(['user' => $f['receiver']->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id, 'input' => ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação concorrente da parametrização testada.', 'data' => []]], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
|
||||
$actor = User::query()->withoutGlobalScopes()->findOrFail($data['user']);
|
||||
auth()->guard()->setUser($actor);
|
||||
if (($argv[1] ?? '') === 'hold') {
|
||||
$held = false;
|
||||
DB::listen(function (QueryExecuted $query) use (&$held) {
|
||||
if (! $held && str_contains($query->sql, '"tenants"') && str_contains(strtolower($query->sql), 'for update')) {
|
||||
$held = true;
|
||||
echo "locked\n";
|
||||
flush();
|
||||
usleep(2000000);
|
||||
}
|
||||
});
|
||||
}
|
||||
$rule = app(AutomaticPurchases::class)->save($actor, $data['input']);
|
||||
$need = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $data['stock'])->where('warehouse_id', $data['warehouse'])->sole();
|
||||
echo json_encode(['result' => 'saved', 'rule' => $rule->id, 'purchases' => $need->data['purchases']], JSON_THROW_ON_ERROR);
|
||||
@@ -1,24 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\ReplenishmentRun;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
function pilotWork($test): int
|
||||
{
|
||||
activateAutomaticRule($test);
|
||||
$queue = Mockery::mock(ReplenishmentQueue::class)->makePartial();
|
||||
$queue->shouldReceive('interactive')->andReturn(false);
|
||||
app()->instance(ReplenishmentQueue::class, $queue);
|
||||
automaticIssue($test, '120.000');
|
||||
|
||||
return DB::table('replenishment_work')->where('tenant_id', $test->f['tenant']->id)->where('stock_item_id', $test->f['stock']->id)->value('id');
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = suggestionFixture();
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$policy=app(SupplyPlanning::class)->save($this->f['issuer'],'policy',supplyInput([...supplyPolicy($this->f),'purpose'=>'internal','agreement_key'=>$this->f['agreement']->record_key]));
|
||||
$policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->f['policy']=app(SupplyPlanning::class)->save($this->f['receiver'],'policy',supplyInput([],'approve',$policy));
|
||||
$this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->input = ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização autorizada para o ensaio isolado.',
|
||||
'data' => ['policy_key' => $this->f['policy']->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]];
|
||||
@@ -33,11 +57,11 @@ function activateAutomaticRule($test)
|
||||
return app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Activação independente da parametrização verificada.', 'data' => []]);
|
||||
}
|
||||
|
||||
function automaticIssue($test, string $quantity)
|
||||
function automaticIssue($test, string $quantity, array $extra = [])
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.']);
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.', ...$extra]);
|
||||
}
|
||||
|
||||
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals', function () {
|
||||
@@ -66,6 +90,145 @@ test('missing configuration records a pending need and never invents a requester
|
||||
expect($need->state)->toBe('pending')->and($need->data['purchases'])->toBe([])->and($need->data['message'])->toContain('Parametrize')->and(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('durable signals survive the missing immediate callback and scheduled recovery never impersonates or duplicates drafts', function () {
|
||||
$id = pilotWork($this);
|
||||
$before = Requisition::query()->count();
|
||||
$row = DB::table('replenishment_work')->find($id);
|
||||
expect($row->generation)->toBeGreaterThan($row->processed_generation);
|
||||
auth()->guard()->logout();
|
||||
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue()->and(auth()->check())->toBeFalse();
|
||||
$r = Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->latest('id')->firstOrFail();
|
||||
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and($r->status)->toBe('draft');
|
||||
$audit = DB::table('audit_logs')->where('requisition_id', $r->id)->where('action', 'requisition.automatically_created')->sole();
|
||||
expect(json_decode($audit->after, true)['execution']['mode'])->toBe('scheduled_replenishment')->and($audit->ip_address)->toBeNull();
|
||||
DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
|
||||
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
|
||||
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1)->and(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
||||
});
|
||||
|
||||
test('failure after draft creation rolls back the draft and checkpoint before a successful retry', function () {
|
||||
$id = pilotWork($this);
|
||||
$before = Requisition::query()->count();
|
||||
$checkpoint = DB::table('replenishment_work')->where('id', $id)->value('processed_generation');
|
||||
auth()->guard()->logout();
|
||||
$injected = false;
|
||||
DB::listen(function ($query) use (&$injected) {
|
||||
if (! $injected && str_contains($query->sql, 'replenishment_work') && str_contains($query->sql, 'processed_generation') && str_starts_with(strtolower($query->sql), 'update')) {
|
||||
$injected = true;
|
||||
throw new RuntimeException('Injected checkpoint interruption');
|
||||
}
|
||||
});
|
||||
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeFalse();
|
||||
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before);
|
||||
$row = DB::table('replenishment_work')->find($id);
|
||||
expect($row->processed_generation)->toBe($checkpoint)->and($row->failures)->toBe(1);
|
||||
DB::table('replenishment_work')->where('id', $id)->update(['due_at' => now()->subMinute()]);
|
||||
expect(app(ReplenishmentQueue::class)->scheduled($id))->toBeTrue();
|
||||
expect(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('periodic discovery rechecks configured minimum and expired rules without stock movements', function () {
|
||||
activateAutomaticRule($this);
|
||||
automaticIssue($this, '100.000');
|
||||
auth()->guard()->logout();
|
||||
$queue = app(ReplenishmentQueue::class);
|
||||
$queue->discover();
|
||||
expect($queue->drain(100)['failed'])->toBe(0);
|
||||
$need = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
|
||||
expect($need->data['purchases'])->toHaveCount(1);
|
||||
$count = DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count();
|
||||
$this->travel(32)->days();
|
||||
expect($queue->drain(100)['failed'])->toBe(0);
|
||||
$latest = SupplyRecord::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->where('kind', 'auto_need')->latest('id')->firstOrFail();
|
||||
expect($latest->state)->toBe('pending')->and($latest->data['message'])->toContain('expirou')->and(DB::table('stock_movements')->where('tenant_id', $this->f['tenant']->id)->count())->toBe($count);
|
||||
});
|
||||
|
||||
test('pending replenishment pagination retains old needs and never exposes another tenant', function () {
|
||||
$tenant = $this->f['tenant']->id;
|
||||
for ($i = 0; $i < 105; $i++) {
|
||||
SupplyRecord::query()->create(['tenant_id' => $tenant, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'created_by' => $this->f['issuer']->id, 'data' => ['message' => 'Necessidade pendente de teste.'], 'snapshot' => ['item' => ['id' => $this->f['stock']->id], 'warehouse' => ['id' => $this->f['warehouse']->id], 'origins' => []], 'reason' => 'Registo isolado para conferir paginação.', 'operation_key' => (string) Str::uuid(), 'input_hash' => hash('sha256', (string) $i), 'created_at' => now()->subDays(106 - $i)]);
|
||||
}
|
||||
$first = SupplyRecord::query()->where('kind', 'auto_need')->oldest('id')->value('id');
|
||||
$foreign = receiptFixture();
|
||||
SupplyRecord::query()->create(['tenant_id' => $foreign['tenant']->id, 'kind' => 'auto_need', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'pending', 'stock_item_id' => $foreign['stock']->id, 'warehouse_id' => $foreign['warehouse']->id, 'created_by' => $foreign['issuer']->id, 'data' => ['message' => 'Outra empresa'], 'snapshot' => [], 'reason' => 'Origem de outra empresa no ensaio isolado.', 'operation_key' => (string) Str::uuid(), 'input_hash' => str_repeat('a', 64), 'created_at' => now()->subYear()]);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->get('/stock/reposicao?need_state=pending')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 20)->where('needs.data.0.id', $first)->where('needs.current_page', 1));
|
||||
$this->get('/stock/reposicao?need_state=pending&needs_page=6')->assertOk()->assertInertia(fn (Assert $p) => $p->where('needs.total', 105)->has('needs.data', 5)->where('needs.current_page', 6));
|
||||
});
|
||||
|
||||
test('a rolled back physical command also rolls back its durable replenishment signal', function () {
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$before = DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation');
|
||||
expect(fn () => DB::transaction(function () {
|
||||
automaticIssue($this, '1.000');
|
||||
throw new RuntimeException('Interrupted physical transaction');
|
||||
}))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->value('generation'))->toBe($before)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(120000);
|
||||
});
|
||||
|
||||
test('background execution cannot be borrowed by an authenticated session', function () {
|
||||
$id = pilotWork($this);
|
||||
expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
|
||||
expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
||||
});
|
||||
|
||||
test('the pending work list exposes interrupted articles but recovery still requires an explicit operational task', function () {
|
||||
$id = pilotWork($this);
|
||||
DB::table('replenishment_work')->where('id', $id)->update(['failures' => 1, 'last_error' => 'Recuperação automática pendente.']);
|
||||
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
||||
supplyGrant($admin, ['admin']);
|
||||
$this->actingAs($admin)->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', false)->where('workItems.total', 1)->where('workItems.data.0.id', $id));
|
||||
$this->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertForbidden();
|
||||
$this->actingAs($this->f['issuer'])->post('/stock/reposicao/trabalhos/'.$id.'/avaliar')->assertRedirect();
|
||||
});
|
||||
|
||||
test('replenishment workers do not process the SGF composition', function () {
|
||||
$id = pilotWork($this);
|
||||
auth()->guard()->logout();
|
||||
config(['work_context.composition' => 'sgf']);
|
||||
expect(app(ReplenishmentQueue::class)->ready())->toBeFalse();
|
||||
expect(fn () => app(ReplenishmentQueue::class)->scheduled($id))->toThrow(HttpException::class);
|
||||
expect(app(ReplenishmentRun::class)->tenantId())->toBeNull();
|
||||
});
|
||||
|
||||
test('periodic evaluation detects lot expiry without physical movements and uses the existing draft workflow', function () {
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$setup = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'EXP-PILOT', 'expires_on' => today()->addDays(2)->toDateString()]]], 'prepare'));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
|
||||
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
activateAutomaticRule($this);
|
||||
$before = Requisition::query()->count();
|
||||
$moves = DB::table('stock_movements')->count();
|
||||
auth()->guard()->logout();
|
||||
$this->travel(3)->days();
|
||||
$queue = app(ReplenishmentQueue::class);
|
||||
$queue->discover();
|
||||
expect($queue->drain(100)['failed'])->toBe(0)->and(DB::table('stock_movements')->count())->toBe($moves)
|
||||
->and(Requisition::withoutGlobalScopes()->where('tenant_id', $this->f['tenant']->id)->count())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('a rejected automatic purchase does not reopen itself or repeat the rejected purchase', function () {
|
||||
activateAutomaticRule($this);
|
||||
automaticIssue($this, '120.000');
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $r);
|
||||
$reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
||||
supplyGrant($reviewer, ['department_head']);
|
||||
$this->actingAs($reviewer);
|
||||
app(RequisitionWorkflow::class)->decide($reviewer, $r->fresh(), 'rejected', 'Compra não autorizada para esta necessidade.');
|
||||
app(AutomaticPurchases::class)->evaluate($reviewer, $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$current = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
expect($r->fresh()->status)->toBe('rejected')->and($current->state)->toBe('pending')->and($current->data['message'])->toContain('rejeitada')->and($current->data['purchases'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules', function () {
|
||||
supplyGrant($this->f['issuer'], ['supply_review']);
|
||||
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input);
|
||||
@@ -92,6 +255,55 @@ test('expired or changed sources keep the need pending without preventing the ph
|
||||
test('configuration UI separates preparation review and read and does not generate purchases on GET', function () {
|
||||
$before = Requisition::query()->count();
|
||||
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/replenishment')->where('canPrepare', true)->where('canReview', false)->has('requesters', 1));
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters',0));
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters', 0));
|
||||
expect(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('warehouse shortage carries the original destination and equipment and is not counted twice after reservation', function () {
|
||||
activateAutomaticRule($this);
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
|
||||
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-OF', 'description' => 'Equipamento da oficina central', 'area' => 'Oficina', 'status' => 'active']);
|
||||
$request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'destination' => 'Oficina central, bancada de montagem', 'reason' => 'Componentes para reposição e manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '125.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
|
||||
app(WarehouseFulfilment::class)->command($this->f['issuer'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'reason' => 'Submissão do material para utilização interna.', 'partial_allowed' => true]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(WarehouseFulfilment::class)->command($this->f['receiver'], $request->id, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Autorização parcial com saldo em falta.', 'partial_allowed' => true]);
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
||||
expect($r->items()->sole()->quantity)->toBe('108.000')->and($need->snapshot['origins'][0]['destination'])->toBe('Oficina central, bancada de montagem')
|
||||
->and($need->snapshot['origins'][0]['reserved_milli'])->toBe(120000)->and($need->snapshot['unreserved_milli'])->toBe(5000);
|
||||
$origin = app(AutomaticPurchases::class)->origin($r);
|
||||
expect($origin['origins'][0]['warehouse_request_id'])->toBe($request->id);
|
||||
expect($origin['origins'][0]['equipment']['code'])->toBe('EQ-OF');
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/'.$r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('automaticSource.origins.0.destination', 'Oficina central, bancada de montagem'));
|
||||
});
|
||||
|
||||
test('revoked requester authority or expired work context cannot be borrowed for automatic purchases', function () {
|
||||
activateAutomaticRule($this);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$before = Requisition::query()->count();
|
||||
automaticIssue($this, '120.000');
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->data['message'])->toContain('competência')->and(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('automatic spare part purchases reuse the registered compatible equipment and reject a missing technical destination', function () {
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PART', 'name' => 'Normal', 'criteria' => 'Critério do ensaio.', 'is_critical' => false, 'created_at' => now()]);
|
||||
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id, 'code' => 'EQ-P', 'description' => 'Máquina de teste', 'area' => 'Oficina', 'status' => 'active']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 20000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 1, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']), 'category' => 'Peças', 'purchase_type' => 'spare_part', 'published' => true, 'reason' => 'Peça publicada para aquisição com destino técnico.']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input))->toThrow(ValidationException::class);
|
||||
$this->input['data']['equipment_id'] = $equipment->id;
|
||||
activateAutomaticRule($this);
|
||||
automaticIssue($this, '120.000', ['equipment_id' => $equipment->id]);
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
||||
expect($r->items()->sole()->equipment_id)->toBe($equipment->id)->and($r->items()->sole()->spare_part_id)->toBe($part->id)->and($r->status)->toBe('draft');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\{GoodsReceipt,GoodsReceiptItem,StockMovement};
|
||||
use App\Services\{ConflictGuard,ReceiptStockMovement,ReceiptWorkflow};
|
||||
use Illuminate\Support\Facades\{Artisan,DB};
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
final class CommonReceiptStockTest extends TestCase
|
||||
{
|
||||
public function test_native_receipt_shared_writer_and_single_book_evidence(): void
|
||||
{
|
||||
$i=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertMatchesRegularExpression('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name);$tag=substr($i->name,-12);
|
||||
$this->assertSame('stock_receipt_qa_'.$tag,$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
|
||||
if(!DB::getSchemaBuilder()->hasTable('users')){Artisan::call('migrate',['--force'=>true]);}
|
||||
$this->assertSame(0,DB::table('users')->count(),'Use the fresh QA database, never a retained fixture');
|
||||
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);
|
||||
$f=\receiptFixture('10.000');$this->actingAs($f['receiver']);$url='/ordens/'.$f['order']->id.'/recepcoes';
|
||||
$body=\receiptPayload($f,'6.000');$body['items'][0]['accepted_quantity']='4.000';$body['items'][0]['quality_status']='reservations';$body['items'][0]['notes']='Synthetic acceptance: four accepted, two damaged.';
|
||||
$receipt=$this->postJson($url,$body)->assertCreated()->json('id');
|
||||
$this->postJson($url,$body)->assertOk()->assertJsonPath('id',$receipt)->assertJsonPath('created',false);
|
||||
$line=GoodsReceiptItem::query()->where('goods_receipt_id',$receipt)->firstOrFail();$movement=StockMovement::query()->where('goods_receipt_item_id',$line->id)->firstOrFail();
|
||||
$this->assertSame(4000,(int)$movement->quantity_milli);$this->assertSame(2000,(int)$line->rejected_milli);
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/D',$movement->snapshot['inventory_source_hash']);
|
||||
$coreReplay=DB::transaction(function () use ($f,$line,$movement){
|
||||
app(ConflictGuard::class)->lockCompany($f['receiver']);$w=app(ReceiptStockMovement::class)->for($f['receiver']);
|
||||
return $w->write($line->id,$movement->snapshot['inventory_source_hash']);
|
||||
});$this->assertTrue($coreReplay['replayed']);$this->assertSame($movement->id,$coreReplay['movement_id']);
|
||||
try{DB::transaction(function()use($f,$line){app(ConflictGuard::class)->lockCompany($f['receiver']);app(ReceiptStockMovement::class)->for($f['receiver'])->write($line->id,str_repeat('0',64));});$this->fail('Old origin hash must be refused');}
|
||||
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}
|
||||
$bad=$body;$bad['items'][0]['accepted_quantity']='3.000';$this->postJson($url,$bad)->assertUnprocessable();
|
||||
$rejected=\receiptPayload($f,'2.000');$rejected['warehouse_id']=null;$rejected['items'][0]['stock_item_id']=null;$rejected['items'][0]['accepted_quantity']='0';$rejected['items'][0]['quality_status']='nonconforming';$rejected['items'][0]['notes']='Synthetic rejection.';
|
||||
$this->postJson($url,$rejected)->assertCreated()->assertJsonPath('status','rejected');
|
||||
$this->assertSame(1,StockMovement::query()->count());
|
||||
$this->actingAs($f['issuer'])->postJson($url,\receiptPayload($f,'1.000'))->assertForbidden();$this->actingAs($f['receiver']);
|
||||
// Fault after the shared SQL insert must undo receipt, movement and native audit.
|
||||
$failure=\receiptFixture();$this->actingAs($failure['receiver']);$payload=\receiptPayload($failure,'1.000');$fault=true;
|
||||
$before=array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']);
|
||||
DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*stock_movements/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic failure after inventory insert');}});
|
||||
try{app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);$this->fail('Injected failure missing');}
|
||||
catch(\RuntimeException $e){$this->assertSame('Synthetic failure after inventory insert',$e->getMessage());}
|
||||
$this->assertSame($before,array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']));
|
||||
$recovered=app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);
|
||||
$this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$recovered->items->pluck('id'))->count());
|
||||
// Two real PHP processes contend on the company lock, then recover the same receipt.
|
||||
$concurrent=\receiptFixture();$this->actingAs($concurrent['receiver']);$payload=\receiptPayload($concurrent,'1.000');$process=null;
|
||||
DB::beginTransaction();
|
||||
try{
|
||||
$winner=app(ReceiptWorkflow::class)->confirm($concurrent['receiver'],$concurrent['order'],$payload);
|
||||
$input=$root.'/worker.json';$application='stock_receipt_worker_'.$tag;
|
||||
file_put_contents($input,json_encode(['actor'=>$concurrent['receiver']->id,'order'=>$concurrent['order']->id,'payload'=>$payload,'application'=>$application],JSON_THROW_ON_ERROR));
|
||||
$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/common-receipt-worker.php'),$input],[0=>['pipe','r'],1=>['file',$root.'/worker.stdout','w'],2=>['file',$root.'/worker.stderr','w']],$pipes,base_path());
|
||||
$this->assertIsResource($process);fclose($pipes[0]);$deadline=microtime(true)+10;$wait=null;$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
|
||||
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT wait_event,to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);
|
||||
$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
|
||||
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);
|
||||
$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);
|
||||
$second=json_decode(file_get_contents($input.'.result'),true,512,JSON_THROW_ON_ERROR);$this->assertSame(200,$second['status']);$this->assertSame($winner->id,$second['receipt_id']);
|
||||
$this->assertSame(1,GoodsReceipt::query()->where('purchase_order_id',$concurrent['order']->id)->count());
|
||||
$this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$winner->items->pluck('id'))->count());
|
||||
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
file_put_contents($root.'/receipt-results.json',json_encode(['identity'=>$i,'physical_accepted_milli'=>4000,'rejected_milli'=>2000,'movement_count_main'=>1,
|
||||
'source_version_stale_refused'=>true,'core_replay_same_movement'=>true,'fault_atomic_and_recovered'=>true,'concurrency'=>['actual_lock_wait'=>true,'wait_event'=>$wait->wait_event,'same_receipt'=>true,'one_movement'=>true],
|
||||
'link'=>['tenant'=>(int)$f['tenant']->id,'warehouse'=>(int)$f['warehouse']->id,'item'=>(int)$f['stock']->id,'movement'=>(int)$movement->id,'reference'=>$movement->snapshot['reference']],
|
||||
'writer_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-write/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class CommonStockReadTest extends TestCase
|
||||
{
|
||||
public function test_authenticated_native_composition_and_common_reader(): void
|
||||
{
|
||||
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
|
||||
$identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertMatchesRegularExpression('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name);
|
||||
$tag=substr($identity->name,-12);
|
||||
$this->assertSame('stock_read_qa_'.$tag,$identity->role);$this->assertSame(55439,(int)$identity->port);$this->assertSame('sgf_modular_regression_'.$run,$identity->cluster);
|
||||
// Fresh, dedicated QA database only. Never migrate/reset any existing host.
|
||||
$this->assertFalse(DB::getSchemaBuilder()->hasTable('users'));
|
||||
DB::transaction(function () {
|
||||
DB::statement('CREATE TABLE tenants(id bigint PRIMARY KEY,name text,currency text)');
|
||||
DB::statement("INSERT INTO tenants VALUES(77,'Synthetic native company','MZN'),(88,'Foreign company','MZN')");
|
||||
DB::statement('CREATE TABLE users(id bigserial PRIMARY KEY,tenant_id bigint,department_id bigint,name text,email text,password text,purchasing_role text,email_verified_at timestamp,remember_token text,created_at timestamp,updated_at timestamp)');
|
||||
DB::statement('CREATE TABLE organization_access(tenant_id bigint,active boolean)');
|
||||
DB::statement('CREATE TABLE organization_profiles(id bigint PRIMARY KEY,tenant_id bigint,name text,permissions jsonb,work_contexts jsonb)');
|
||||
DB::statement('CREATE TABLE organization_memberships(id bigint PRIMARY KEY,tenant_id bigint,user_id bigint,profile_id bigint,expires_at timestamp,maintenance boolean)');
|
||||
});
|
||||
$actor=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'warehouse','name'=>'Synthetic warehouse','email'=>'stock@example.test','password'=>bin2hex(random_bytes(16))]);
|
||||
$denied=User::forceCreate(['tenant_id'=>77,'purchasing_role'=>'requester','name'=>'Synthetic requester','email'=>'request@example.test','password'=>bin2hex(random_bytes(16))]);
|
||||
$schema='sgf_stock_read_'.bin2hex(random_bytes(6));
|
||||
DB::transaction(function () use ($schema) {
|
||||
DB::statement("CREATE SCHEMA $schema");
|
||||
DB::statement("CREATE TABLE $schema.stock_items(id bigint PRIMARY KEY,tenant_id bigint,code text,name text,unit text)");
|
||||
DB::statement("CREATE TABLE $schema.warehouses(id bigint PRIMARY KEY,tenant_id bigint,code text,name text)");
|
||||
DB::statement("CREATE TABLE $schema.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint,stock_item_id bigint,warehouse_id bigint,quantity_milli bigint,movement_type text,snapshot jsonb,moved_at timestamp)");
|
||||
DB::statement("INSERT INTO $schema.warehouses VALUES(11,77,'WH-A','Synthetic A'),(12,77,'WH-B','Synthetic B'),(13,77,'HIDDEN','Restricted warehouse'),(21,88,'FOREIGN','Foreign warehouse')");
|
||||
DB::statement("INSERT INTO $schema.stock_items SELECT i,77,'QA-'||lpad(i::text,6,'0'),'Synthetic item '||i,'un' FROM generate_series(1,100) i");
|
||||
DB::statement("INSERT INTO $schema.stock_items VALUES(101,77,'ZERO','Without movements','un'),(9999999,88,'SECRET','Foreign item','un')");
|
||||
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
|
||||
SELECT 77,i,11,10000,'receipt',jsonb_build_object('reference','QA-R-'||i,'private_document','DO_NOT_RETURN'),'2026-11-01'::timestamp FROM generate_series(1,100) i");
|
||||
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at)
|
||||
SELECT 77,i,11,-2000,'issue',jsonb_build_object('reference','QA-I-'||i),'2026-11-02'::timestamp FROM generate_series(1,100) i");
|
||||
DB::statement("INSERT INTO $schema.stock_movements(tenant_id,stock_item_id,warehouse_id,quantity_milli,movement_type,snapshot,moved_at) VALUES(77,1,13,99000,'hidden','{}',now()),(88,9999999,21,88000,'foreign','{}',now())");
|
||||
DB::statement("CREATE FUNCTION $schema.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Inventory ledger is append-only''; END'");
|
||||
DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $schema.stock_movements FOR EACH ROW EXECUTE FUNCTION $schema.immutable()");
|
||||
});
|
||||
$binding=['schema'=>$schema,'source_tenant_id'=>77,'warehouse_ids'=>[11,12],'append_only'=>true];
|
||||
$key='77:'.$actor->id;config(['stock_read.synthetic_sources.'.$key=>$binding]);
|
||||
$this->getJson('/api/stock-read')->assertUnauthorized();
|
||||
$this->actingAs($denied)->getJson('/api/stock-read')->assertForbidden();
|
||||
$this->actingAs($actor);
|
||||
$queries=[];foreach(['items','warehouses','balances','movements'] as $view)foreach([25,100] as $size)$queries[]='view='.$view.'&per_page='.$size;
|
||||
$queries=[...$queries,'view=balances&per_page=25&page=2','view=items&per_page=25&page=5','view=balances&warehouse_id=12','view=items&search=QA-000001'];
|
||||
$cases=[];
|
||||
foreach($queries as $query){
|
||||
$r=$this->getJson('/api/stock-read?'.$query)->assertOk();
|
||||
$this->assertStringNotContainsString('DO_NOT_RETURN',$r->getContent());$this->assertStringNotContainsString('Foreign',$r->getContent());$this->assertStringNotContainsString('Restricted warehouse',$r->getContent());
|
||||
$cases[$query]=['sha256'=>hash('sha256',json_encode([$r->json('rows'),$r->json('pagination')],JSON_THROW_ON_ERROR)),'total'=>$r->json('pagination.total')];
|
||||
}
|
||||
$snapshot=$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000')->json('snapshot');
|
||||
$this->getJson('/api/stock-read?warehouse_id=13')->assertForbidden();$this->getJson('/api/stock-read?warehouse_id=21')->assertForbidden();
|
||||
$this->getJson('/api/stock-read?tenant_id=88')->assertUnprocessable();$this->getJson('/api/stock-read?per_page=101')->assertUnprocessable();
|
||||
$this->getJson('/api/stock-read?snapshot=invalid')->assertConflict();
|
||||
DB::table($schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>-1000,'movement_type'=>'issue','snapshot'=>'{}','moved_at'=>now()]);
|
||||
$this->getJson('/api/stock-read?snapshot='.urlencode($snapshot))->assertConflict();
|
||||
$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','7000');
|
||||
// Exercise configured native permissions too, without inventing a new right.
|
||||
DB::table('organization_access')->insert(['tenant_id'=>77,'active'=>true]);
|
||||
DB::table('organization_profiles')->insert(['id'=>1,'tenant_id'=>77,'name'=>'Synthetic warehouse profile','permissions'=>'["warehouse"]','work_contexts'=>'[]']);
|
||||
DB::table('organization_memberships')->insert(['id'=>1,'tenant_id'=>77,'user_id'=>$actor->id,'profile_id'=>1,'maintenance'=>false]);
|
||||
$this->getJson('/api/stock-read')->assertOk();
|
||||
DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["requester"]']);
|
||||
$this->getJson('/api/stock-read')->assertForbidden();
|
||||
DB::table('organization_profiles')->where('id',1)->update(['permissions'=>'["warehouse"]']);
|
||||
$this->getJson('/api/stock-read')->assertOk();
|
||||
config(['stock_read.synthetic_sources.'.$key=>null]);$this->getJson('/api/stock-read')->assertStatus(503)->assertJsonPath('status','unavailable');
|
||||
config(['stock_read.synthetic_sources.'.$key=>array_replace($binding,['source_tenant_id'=>88])]);$this->getJson('/api/stock-read')->assertStatus(503);
|
||||
config(['stock_read.synthetic_sources.'.$key=>$binding]);$this->getJson('/api/stock-read')->assertOk();
|
||||
$output=getenv('STOCK_READ_COMPOSITION_OUTPUT');$root=realpath((string)getenv('STOCK_READ_COMPOSITION_ROOT'));
|
||||
if(!$output||!$root||realpath(dirname($output))!==$root)throw new \RuntimeException('Unexpected private proof directory');
|
||||
file_put_contents($output,json_encode(['identity'=>$identity,'cases'=>$cases,'native_snapshot'=>$snapshot,'native_roles_and_profiles_revalidated'=>true,
|
||||
'warehouse_isolation'=>true,'stale_snapshot_refused'=>true,'unknown_source_refused'=>true,'package_manifest_sha256'=>config('stock_read.package_manifest_sha256')],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/QualificationFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = qualificationFixture();
|
||||
@@ -51,6 +52,46 @@ test('documentary qualification freezes process criteria without financial decis
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('kind', 'Qualificação documental')));
|
||||
});
|
||||
|
||||
test('formal task documentary resumes only my incomplete analysis and reuses the existing answers', function () {
|
||||
$set = defineDocumentaryQualification($this);
|
||||
$first = recordDocumentaryAssessment($this, $set);
|
||||
$path = '/requisicoes/tarefas?module=purchases&search=documental';
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $this->url.'?version='.$set->id.'"ation_id='.$this->f['quote']->id.'#qualification-assess'));
|
||||
$this->get($this->url.'?version='.$set->id.'"ation_id='.$this->f['quote']->id)->assertInertia(fn (Assert $page) => $page
|
||||
->where('focusQuotationId', $this->f['quote']->id)->where('canAssess', true)->where('records.0.id', $first->id)
|
||||
->where('records.0.answers.0.evidence', $this->f['assessment']['answers'][0]['evidence']));
|
||||
$this->actingAs($this->f['other_analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$answers = $first->answers;
|
||||
$answers[1] = ['criterion_id' => 2, 'result' => 'not_met', 'evidence' => 'Documento sintético', 'reason' => 'Resultado negativo fundamentado no ensaio.'];
|
||||
recordDocumentaryAssessment($this, $set, ['previous_id' => $first->id, 'answers' => $answers]);
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect($first->fresh()->answers[1]['result'])->toBe('pending')->and(QualificationAssessment::query()->count())->toBe(2)->and(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('formal task documentary does not continue an old basis or create new analyst assignments', function () {
|
||||
$set = defineDocumentaryQualification($this);
|
||||
$first = recordDocumentaryAssessment($this, $set);
|
||||
$before = [QualificationAssessment::query()->count(), AuditLog::query()->count()];
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=documental')->assertOk();
|
||||
expect([QualificationAssessment::query()->count(), AuditLog::query()->count()])->toBe($before);
|
||||
$this->f['quote']->update(['reference' => 'REFERÊNCIA-ALTERADA']);
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=documental')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?quotation_id='.$this->f['quote']->id)->assertInertia(fn (Assert $page) => $page->where('isCurrent', false)->where('canAssess', false));
|
||||
$this->post('/qualificacoes/'.$set->id.'/analises', [...$this->f['assessment'], 'previous_id' => $first->id])->assertSessionHasErrors('qualification');
|
||||
$this->getJson($this->url.'?quotation_id=999999')->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('formal task documentary does not let an administrative reading grant authorise another department analysis', function () {
|
||||
$set = defineDocumentaryQualification($this);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento sintético']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
|
||||
supplyGrant($head, ['admin', 'department_head']);
|
||||
$this->actingAs($head)->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canAssess', false));
|
||||
$this->post('/qualificacoes/'.$set->id.'/analises', $this->f['assessment'])->assertForbidden();
|
||||
expect(QualificationAssessment::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('documentary criteria versions and analyst corrections never overwrite earlier records', function () {
|
||||
$set = defineDocumentaryQualification($this);
|
||||
$first = recordDocumentaryAssessment($this, $set);
|
||||
|
||||
@@ -206,10 +206,12 @@ test('financial role is reloaded after the company lock rather than trusting a s
|
||||
expect(ExternalPayment::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('payables trial stays unavailable outside its isolated database even with a source configured', function () {
|
||||
config(['common-payables.source_instance' => 'synthetic-source']);
|
||||
test('payables trial never inherits financial authority and only activates automatically in isolated PostgreSQL', function () {
|
||||
config(['common-payables.enabled' => false, 'common-payables.source_instance' => 'synthetic-source']);
|
||||
|
||||
expect(SupplierPayables::enabled())->toBeFalse();
|
||||
// paymentFixture rejects PostgreSQL databases outside the disposable test namespace.
|
||||
// The same source alone keeps the module disabled on the normal SQLite suite.
|
||||
expect(SupplierPayables::enabled())->toBe(DB::getDriverName() === 'pgsql');
|
||||
$this->get('/obrigacoes-fornecedores')->assertForbidden();
|
||||
$this->getJson('/api/supplier-payables')->assertForbidden();
|
||||
$this->postJson('/api/supplier-payables/prepare', ['id' => $this->f['invoice']->id, 'reason' => 'Ensaio isolado'])->assertForbidden();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/InvoiceFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = invoiceFixture();
|
||||
$this->actingAs($this->f['author']);
|
||||
});
|
||||
|
||||
test('compact invoice registration reuses order facts and preserves independent totals and validation', function () {
|
||||
$data = [...$this->f['data'], 'number' => 'FT-COMPACT-2', 'subtotal' => '40.00', 'total' => '40.00',
|
||||
'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.400']]];
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/facturas', $data)->assertRedirect();
|
||||
$invoice = Invoice::query()->where('number', 'FT-COMPACT-2')->sole();
|
||||
expect($invoice->status)->toBe('pending')->and($invoice->items()->sole()->snapshot['unit'])->toBe('cx')
|
||||
->and((int) $invoice->items()->sole()->snapshot['unit_price_minor'])->toBe(10000)->and(BudgetConsumption::query()->count())->toBe(0);
|
||||
$this->actingAs($this->f['validator']);
|
||||
app(InvoiceWorkflow::class)->validate($this->f['validator'], $this->f['invoice']);
|
||||
app(InvoiceWorkflow::class)->validate($this->f['validator'], $invoice);
|
||||
expect((int) BudgetConsumption::query()->sum('amount_minor'))->toBe(10000);
|
||||
});
|
||||
|
||||
test('compact registration still refuses forged supplied facts quantity and documentary totals', function () {
|
||||
$base = [...$this->f['data'], 'number' => 'FORGED', 'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '0.600']]];
|
||||
foreach ([['unit' => 'kg'], ['unit_price' => '99.00'], ['tax_rate' => '16'], ['quantity' => '2.000']] as $extra) {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/facturas', [...$base, 'items' => [[...$base['items'][0], ...$extra]]])->assertUnprocessable();
|
||||
}
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/facturas', [...$base, 'total' => '59.99'])->assertUnprocessable();
|
||||
expect(Invoice::query()->count())->toBe(1)->and(BudgetConsumption::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('finance task belongs to an independent finance user and reaches the exact invoice', function () {
|
||||
$invoice = $this->f['invoice'];
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['validator'])->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id));
|
||||
$this->get('/facturas?invoice_id='.$invoice->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->where('focusInvoiceId', $invoice->id)->where('invoices.total', 1)->where('invoices.data.0.canValidate', true));
|
||||
$this->post('/facturas/'.$invoice->id.'/validar')->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['author'])->get('/facturas?purchase_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->where('focusOrderId', $this->f['order']->id));
|
||||
});
|
||||
|
||||
test('finance tasks respect declared conflicts current permissions and maintenance without changing facts', function () {
|
||||
$actor = $this->f['validator'];
|
||||
$this->actingAs($actor);
|
||||
$before = [DB::table('invoices')->get()->toArray(), DB::table('budget_consumptions')->count(), DB::table('audit_logs')->count()];
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertOk();
|
||||
expect([DB::table('invoices')->get()->toArray(), DB::table('budget_consumptions')->count(), DB::table('audit_logs')->count()])->toEqual($before);
|
||||
$guard = Mockery::mock(ConflictGuard::class)->makePartial();
|
||||
$guard->shouldReceive('processBlocked')->andReturn(true);
|
||||
app()->instance(ConflictGuard::class, $guard);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/facturas')->assertInertia(fn (Assert $page) => $page->where('invoices.data.0.canValidate', false));
|
||||
});
|
||||
|
||||
test('non finance users cannot inherit invoice validation tasks or cross company invoice focus', function () {
|
||||
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($admin)->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/facturas?invoice_id='.$this->f['invoice']->id)->assertInertia(fn (Assert $page) => $page->where('invoices.data.0.canValidate', false));
|
||||
$foreign = receiptFixture();
|
||||
$foreignFinance = User::factory()->create(['tenant_id' => $foreign['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($foreignFinance)->getJson('/facturas?invoice_id='.$this->f['invoice']->id)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('maintenance finance can consult but never inherits invoice execution', function () {
|
||||
$actor = $this->f['validator'];
|
||||
supplyGrant($actor, ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($actor);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/facturas')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->where('invoices.data.0.canValidate', false));
|
||||
$this->post('/facturas/'.$this->f['invoice']->id.'/validar')->assertForbidden();
|
||||
expect($this->f['invoice']->fresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -12,11 +12,13 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentaryQualificationWorkflow;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/AdmissionFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = admissionFixture();
|
||||
@@ -166,3 +168,71 @@ test('active process conflict prevents conference without creating approval', fu
|
||||
$this->actingAs($this->f['analyst'])->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
|
||||
expect(AdmissionReview::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('formal task admission follows the adopted quorum and removes only the completed personal intervention', function () {
|
||||
$decision = admissionAttempt($this);
|
||||
$path = '/requisicoes/tarefas?module=purchases&search=admissão';
|
||||
$this->actingAs($this->f['analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$decision->id.'#admission-'.$decision->id)
|
||||
->missing('tasks.data.0.snapshot')->missing('tasks.data.0.reason')->missing('tasks.data.0.basis_hash'));
|
||||
$this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('focusDecisionId', $decision->id)->where('history.data.0.canReview', true));
|
||||
// A different company policy does not replace the process's adopted quorum/roles.
|
||||
$this->actingAs($this->f['admin']);
|
||||
app(FormalAdmission::class)->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'roles' => ['department_head'], 'required_count' => 1]);
|
||||
admissionConfer($this, $decision);
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(FormalAdmission::class)->status($decision, $this->f['adoption']))->toBe('pending');
|
||||
$this->actingAs($this->f['other_analyst'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
admissionConfer($this, $decision, $this->f['other_analyst']);
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(AdmissionReview::query()->count())->toBe(2)->and(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('formal task admission retains an older current decision and opens it beyond the first history page', function () {
|
||||
$supplier = $this->f['quote']->supplier->replicate();
|
||||
$supplier->fill(['nuit' => '987654321', 'name' => 'Segundo fornecedor fictício'])->save();
|
||||
$quote = $this->f['quote']->replicate();
|
||||
$quote->fill(['supplier_id' => $supplier->id, 'reference' => 'SEGUNDA-FICTICIA'])->save();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
$set = app(DocumentaryQualificationWorkflow::class)->define($this->f['issuer'], $this->f['r'], [...$this->f['definition'], 'previous_id' => $this->f['set']->id, 'basis_hash' => $opening->basis($opening->proposals($this->f['r']))]);
|
||||
$this->actingAs($this->f['analyst']);
|
||||
foreach ([$this->f['quote'], $quote] as $candidate) {
|
||||
app(DocumentaryQualificationWorkflow::class)->assess($this->f['analyst'], $set, [...$this->f['assessment'], 'quotation_id' => $candidate->id]);
|
||||
}
|
||||
$service = app(FormalAdmission::class);
|
||||
$first = admissionAttempt($this, ['basis_hash' => $service->basis($this->f['r'], $this->f['quote']->id)['hash']]);
|
||||
$previous = 0;
|
||||
for ($index = 0; $index < 15; $index++) {
|
||||
$next = admissionAttempt($this, ['quotation_id' => $quote->id, 'previous_id' => $previous, 'basis_hash' => $service->basis($this->f['r'], $quote->id)['hash']]);
|
||||
$previous = $next->id;
|
||||
}
|
||||
$this->actingAs($this->f['other_analyst'])->get($this->url)->assertInertia(fn (Assert $page) => $page->where('history.total', 16)->where('history.data.0.id', $next->id));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)->where('tasks.data.0.id', 'admission:'.$first->id.':review'));
|
||||
$this->get($this->url.'?version='.$this->f['adoption']->id.'&decision_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('history.total', 1)->where('history.data.0.id', $first->id)->where('history.data.0.canReview', true));
|
||||
});
|
||||
|
||||
test('formal task admission respects changed bases conflicts and consultation never records a decision', function () {
|
||||
$decision = admissionAttempt($this);
|
||||
$this->actingAs($this->f['analyst']);
|
||||
$before = [DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()];
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertOk();
|
||||
$this->get($this->url.'?decision_id='.$decision->id)->assertOk();
|
||||
expect([DB::table('admission_reviews')->count(), DB::table('audit_logs')->count(), DB::table('admission_decisions')->get()->toArray()])->toEqual($before);
|
||||
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['analyst']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito sintético impeditivo.']);
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false));
|
||||
$this->f['quote']->update(['reference' => 'BASE-ALTERADA']);
|
||||
expect(app(FormalAdmission::class)->currentReviewBasis($this->f['r'], $this->f['adoption'], $decision))->toBeFalse();
|
||||
});
|
||||
|
||||
test('formal task admission cannot grant review to maintenance or focus another adoption', function () {
|
||||
$decision = admissionAttempt($this);
|
||||
supplyGrant($this->f['analyst'], ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['analyst']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['analyst'])->get('/requisicoes/tarefas?module=purchases&search=admissão')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?decision_id='.$decision->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)->where('canPropose', false)->where('canAdopt', false));
|
||||
$this->post('/admissoes/'.$decision->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Não executar em manutenção.'])->assertForbidden();
|
||||
expect(AdmissionReview::query()->count())->toBe(0);
|
||||
$this->getJson($this->url.'?decision_id=999999')->assertUnprocessable();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/RecommendationFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = recommendationFixture();
|
||||
@@ -80,6 +81,68 @@ test('recommendation retries count one person once and returned attempt cannot a
|
||||
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||
});
|
||||
|
||||
test('formal task recommendation keeps the adopted quorum and forwards only unfinished personal reviews', function () {
|
||||
$attempt = recommendationAttempt($this);
|
||||
$path = '/requisicoes/tarefas?module=purchases&search=recomendação';
|
||||
$this->actingAs($this->f['reviewer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $this->url.'?version='.$this->f['adoption']->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id)
|
||||
->missing('tasks.data.0.snapshot')->missing('tasks.data.0.basis_hash'));
|
||||
$this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('focusAttemptId', $attempt->id)->where('history.data.0.canReview', true));
|
||||
$this->actingAs($this->f['admin']);
|
||||
app(FormalRecommendation::class)->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'roles' => ['finance'], 'required_count' => 5]);
|
||||
recommendationConfer($this, $attempt);
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['head'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
recommendationConfer($this, $attempt, $this->f['head']);
|
||||
$this->actingAs($this->f['other_reviewer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('confirmed')
|
||||
->and(RecommendationReview::query()->count())->toBe(2)->and(PurchaseAward::query()->count())->toBe(0)->and(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('formal task recommendation never turns a historical focused attempt into the current pending one', function () {
|
||||
$first = recommendationAttempt($this);
|
||||
$previous = $first;
|
||||
for ($index = 0; $index < 10; $index++) {
|
||||
$previous = recommendationAttempt($this, ['previous_id' => $previous->id]);
|
||||
}
|
||||
$this->actingAs($this->f['reviewer'])->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'recommendation:'.$previous->id.':review'));
|
||||
$this->get($this->url.'?attempt_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('history.total', 1)->where('history.data.0.id', $first->id)
|
||||
->where('history.data.0.current', false)->where('history.data.0.canReview', false));
|
||||
$this->get($this->url.'?attempt_id='.$previous->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.current', true)->where('history.data.0.canReview', true));
|
||||
$this->getJson($this->url.'?attempt_id=999999')->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('formal task recommendation respects matrix changes without mutating the attempt or recording decisions', function () {
|
||||
$attempt = recommendationAttempt($this);
|
||||
$this->actingAs($this->f['reviewer']);
|
||||
$before = [DB::table('recommendation_attempts')->get()->toArray(), RecommendationReview::query()->count(), AuditLog::query()->count()];
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertOk();
|
||||
$this->get($this->url.'?attempt_id='.$attempt->id)->assertOk();
|
||||
expect([DB::table('recommendation_attempts')->get()->toArray(), RecommendationReview::query()->count(), AuditLog::query()->count()])->toEqual($before);
|
||||
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->update(['quality' => '1']);
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false));
|
||||
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Base alterada exige nova tentativa.'])->assertSessionHasErrors('recommendation');
|
||||
expect(RecommendationReview::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('formal task recommendation respects department company maintenance and current reviewer authority', function () {
|
||||
$attempt = recommendationAttempt($this);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra unidade sintética']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?attempt_id='.$attempt->id)->assertNotFound();
|
||||
$tenant = Tenant::query()->create(['name' => 'Outra empresa sintética']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($foreign)->get($this->url.'?attempt_id='.$attempt->id)->assertNotFound();
|
||||
supplyGrant($this->f['reviewer'], ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['reviewer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['reviewer'])->get('/requisicoes/tarefas?module=purchases&search=recomendação')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url.'?attempt_id='.$attempt->id)->assertInertia(fn (Assert $page) => $page->where('history.data.0.canReview', false)->where('canPropose', false)->where('canAdopt', false));
|
||||
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Não decidir em manutenção.'])->assertForbidden();
|
||||
expect(RecommendationReview::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('recommendation rejects lower score incomplete matrix and stale admission', function (string $case) {
|
||||
if ($case === 'incomplete') {
|
||||
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->where('evaluator_id', $this->f['analyst']->id)->delete();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\InternalSupplyTrace;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = suggestionFixture();
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$policy = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização sintética para integrar os módulos internos.',
|
||||
'data' => ['policy_key' => $policy->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(AutomaticPurchases::class)->save($this->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Conferência independente da regra no ensaio.', 'data' => []]);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->request = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'destination' => 'Oficina do ensaio', 'reason' => 'Necessidade interna de manutenção para o ensaio.',
|
||||
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '125.000', 'use' => 'internal']]]);
|
||||
app(WarehouseFulfilment::class)->command($this->f['issuer'], $this->request->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'partial_allowed' => true, 'reason' => 'Submissão efectiva do pedido para o ensaio.']);
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['issuer'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$this->need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$this->purchase = Requisition::query()->findOrFail($this->need->data['requisition_ids'][0]);
|
||||
});
|
||||
|
||||
function internalTrace($test, ?User $actor = null): array
|
||||
{
|
||||
$actor ??= $test->f['issuer'];
|
||||
$test->actingAs($actor);
|
||||
|
||||
return app(InternalSupplyTrace::class)->forRequest($actor, $test->request, app(WarehouseFulfilment::class)->progress($test->request));
|
||||
}
|
||||
|
||||
/** A documentary fixture version; not a new business approval or manual-link UI. */
|
||||
function internalTraceVersion($test, array $data, ?array $snapshot = null): SupplyRecord
|
||||
{
|
||||
$old = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$copy = $old->replicate();
|
||||
$copy->fill(['version' => $old->version + 1, 'data' => $data, 'snapshot' => $snapshot ?? $old->snapshot,
|
||||
'operation_key' => (string) Str::uuid(), 'input_hash' => hash('sha256', (string) Str::uuid()), 'created_at' => now()])->save();
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
test('native modules link warehouse shortage to the canonical purchase and its pending human approval', function () {
|
||||
$trace = internalTrace($this);
|
||||
expect($trace['groups'])->toHaveCount(1)->and($trace['groups'][0]['purchases'][0]['id'])->toBe($this->purchase->id)
|
||||
->and($trace['groups'][0]['purchases'][0]['status'])->toBe('draft')->and($trace['groups'][0]['unreserved_milli'])->toBe(125000);
|
||||
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $this->purchase);
|
||||
$trace = internalTrace($this);
|
||||
expect($trace['groups'][0]['purchases'][0]['status'])->toBe('pending')->and($trace['groups'][0]['purchases'][0]['next_task'])->not->toBeNull()
|
||||
->and($this->purchase->fresh()->steps->where('decision', 'approved'))->toHaveCount(0);
|
||||
$this->get('/stock/requisicoes/'.$this->request->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('supplyTrace.groups.0.purchases.0.id', $this->purchase->id));
|
||||
$this->get('/requisicoes/'.$this->purchase->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('automaticSource.origins.0.href', '/stock/requisicoes/'.$this->request->id));
|
||||
});
|
||||
|
||||
test('trace queries reuse documents and do not create purchases decisions movements or reservations', function () {
|
||||
$tables = ['requisitions', 'approval_steps', 'warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'supply_records', 'audit_logs', 'invoices', 'budget_entries'];
|
||||
$before = [];
|
||||
foreach ($tables as $table) {
|
||||
$before[$table] = DB::table($table)->get()->toArray();
|
||||
}
|
||||
internalTrace($this);
|
||||
internalTrace($this);
|
||||
foreach ($tables as $table) {
|
||||
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
|
||||
}
|
||||
});
|
||||
|
||||
test('warehouse responsibilities do not acquire access to other peoples purchase processes or financial values', function () {
|
||||
$trace = internalTrace($this, $this->f['otherReceiver']);
|
||||
expect($trace['groups'][0]['purchases'])->toBe([])->and($trace['groups'][0]['restricted'])->toBeTrue()
|
||||
->and(json_encode($trace))->not->toContain($this->purchase->number)->not->toContain('unit_price')->not->toContain('total_minor');
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/requisicoes/'.$this->request->id)->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page->where('supplyTrace.groups.0.restricted', true)->has('supplyTrace.groups.0.purchases', 0));
|
||||
});
|
||||
|
||||
test('later shared needs do not retrospectively assign a future purchase to the old warehouse request', function () {
|
||||
$new = internalTraceVersion($this, [...$this->need->data, 'requisition_ids' => [$this->purchase->id, $this->f['r']->id]], [...$this->need->snapshot, 'origins' => []]);
|
||||
$trace = internalTrace($this);
|
||||
expect($trace['groups'][0]['current_need_id'])->toBe($new->id)->and($trace['groups'][0]['purchases'])->toHaveCount(1)
|
||||
->and($trace['groups'][0]['purchases'][0]['id'])->toBe($this->purchase->id);
|
||||
});
|
||||
|
||||
test('documentary receipt projection follows the linked issued order and respects the reversal engine', function () {
|
||||
// Already issued order/receipt from the fixture, not the new automatic draft.
|
||||
internalTraceVersion($this, [...$this->need->data, 'requisition_ids' => [$this->f['r']->id]]);
|
||||
$trace = internalTrace($this);
|
||||
$row = $trace['groups'][0]['purchases'][0]['orders'][0];
|
||||
expect($row['id'])->toBe($this->f['order']->id)->and($row['effective_received_milli'])->toBe(120000)
|
||||
->and($row['receipts'][0]['reversed'])->toBeFalse();
|
||||
$corrections = Mockery::mock(InventoryReversals::class)->makePartial();
|
||||
$corrections->shouldReceive('corrections')->with('receipt')->andReturn([$row['receipts'][0]['item_id'] => $this->need]);
|
||||
app()->instance(InventoryReversals::class, $corrections);
|
||||
$trace = internalTrace($this);
|
||||
expect($trace['groups'][0]['purchases'][0]['orders'][0]['effective_received_milli'])->toBe(0)
|
||||
->and($trace['groups'][0]['purchases'][0]['orders'][0]['receipts'][0]['reversed'])->toBeTrue()
|
||||
->and((int) DB::table('goods_receipt_items')->sum('accepted_milli'))->toBe(120000);
|
||||
});
|
||||
|
||||
test('unrelated people and companies cannot read or link warehouse requests through a purchase', function () {
|
||||
$outsider = User::factory()->create(['tenant_id' => $this->f['issuer']->tenant_id, 'department_id' => $this->f['issuer']->department_id]);
|
||||
supplyGrant($outsider, ['procurement']);
|
||||
$this->actingAs($outsider);
|
||||
$origin = app(InternalSupplyTrace::class)->purchaseOrigin($outsider, $this->purchase);
|
||||
expect($origin['origins'][0]['href'])->toBeNull();
|
||||
$this->get('/stock/requisicoes/'.$this->request->id)->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['issuer'], ['admin']);
|
||||
$this->actingAs($foreign['issuer'])->get('/stock/requisicoes/'.$this->request->id)->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationActivationPreview;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function activationPreviewProfile(int $tenant, string $name, array $permissions, array $contexts = []): int
|
||||
{
|
||||
return DB::table('organization_profiles')->insertGetId(['tenant_id' => $tenant, 'name' => $name, 'permissions' => json_encode($permissions, JSON_THROW_ON_ERROR),
|
||||
'work_contexts' => json_encode($contexts, JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
function activationPreviewMember(int $tenant, int $user, int $profile, array $extra = []): void
|
||||
{
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $user, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now(), ...$extra]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->admin = $this->f['issuer'];
|
||||
$this->admin->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$this->second = User::factory()->create(['tenant_id' => $this->admin->tenant_id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->profile = activationPreviewProfile($this->admin->tenant_id, 'Administração sintética', ['admin']);
|
||||
foreach ([$this->admin, $this->second] as $user) {
|
||||
activationPreviewMember($user->tenant_id, $user->id, $this->profile);
|
||||
}
|
||||
DB::table('organization_access')->insert(['tenant_id' => $this->admin->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->actingAs($this->admin);
|
||||
$this->path = '/administracao/parametrizacao';
|
||||
});
|
||||
|
||||
test('activation preview exposes access losses implicit request preparation and profile tasks without writing configuration', function () {
|
||||
$before = [];
|
||||
foreach (['organization_access', 'organization_profiles', 'organization_memberships', 'users', 'tenants', 'audit_logs', 'requisitions', 'purchase_orders'] as $table) {
|
||||
$before[$table] = DB::table($table)->get()->toArray();
|
||||
}
|
||||
$this->get($this->path)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
||||
->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', false)->where('activationPreview.version', 1)
|
||||
->where('activationPreview.permanent_administrators', 2)->where('activationPreview.losing_access', 2)
|
||||
->where('activationPreview.provisional_rules', true)->where('activationPreview.can_activate', false)
|
||||
->where('workflowTasks.procurement.1.key', 'procurement.prepare'));
|
||||
$rows = collect(app(OrganizationActivationPreview::class)->build($this->admin)['users'])->keyBy('id');
|
||||
expect($rows[$this->second->id]['current_permissions'])->toBe(['requester'])
|
||||
->and($rows[$this->second->id]['configured_permissions'])->toBe(['admin'])
|
||||
->and($rows[$this->second->id]['current_can_request'])->toBeTrue()->and($rows[$this->second->id]['configured_can_request'])->toBeFalse()
|
||||
->and($rows[$this->f['receiver']->id]['loses_access'])->toBeTrue();
|
||||
foreach ($before as $table => $snapshot) {
|
||||
expect(DB::table($table)->get()->toArray())->toEqual($snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
test('activation preview agrees with the effective authority after explicit activation and preserves existing processes', function () {
|
||||
DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
|
||||
$before = $this->f['r']->fresh()->toArray();
|
||||
$preview = app(OrganizationActivationPreview::class)->build($this->admin);
|
||||
expect($preview['can_activate'])->toBeTrue();
|
||||
$this->post($this->path.'/activate', ['version' => $preview['version'], 'confirm' => true, 'reason' => 'Activação explícita apenas no ensaio sintético.'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
foreach ($preview['users'] as $person) {
|
||||
$user = User::query()->findOrFail($person['id']);
|
||||
expect(app(OrganizationAccess::class)->permissions($user))->toBe($person['configured_permissions'])
|
||||
->and(app(OrganizationAccess::class)->canRequest($user))->toBe($person['configured_can_request']);
|
||||
}
|
||||
$this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', true)->where('activationPreview.version', 2));
|
||||
expect($this->f['r']->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->where('action', 'organization.activate')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('activation preview counts only permanent independent administrators and honours expiry and maintenance', function () {
|
||||
DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
|
||||
DB::table('organization_memberships')->where('user_id', $this->second->id)->update(['expires_at' => now()->addHour()]);
|
||||
$maintenance = activationPreviewProfile($this->admin->tenant_id, 'Consulta temporária sintética', ['warehouse']);
|
||||
activationPreviewMember($this->admin->tenant_id, $this->f['receiver']->id, $maintenance, ['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$preview = app(OrganizationActivationPreview::class)->build($this->admin);
|
||||
$rows = collect($preview['users'])->keyBy('id');
|
||||
expect($preview['permanent_administrators'])->toBe(1)->and($preview['can_activate'])->toBeFalse()
|
||||
->and($rows[$this->f['receiver']->id]['configured_maintenance_only'])->toBeTrue();
|
||||
$this->postJson($this->path.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Não activar com administração permanente insuficiente.'])->assertUnprocessable()->assertJsonValidationErrors('administrators');
|
||||
$this->travel(2)->hours();
|
||||
$rows = collect(app(OrganizationActivationPreview::class)->build($this->admin)['users'])->keyBy('id');
|
||||
expect($rows[$this->second->id]['configured_permissions'])->toBe([])
|
||||
->and($rows[$this->f['receiver']->id]['configured_permissions'])->toBe([])
|
||||
->and($rows[$this->f['receiver']->id]['configured_maintenance_only'])->toBeFalse()
|
||||
->and(app(OrganizationAccess::class)->active($this->admin->tenant_id))->toBeFalse();
|
||||
});
|
||||
|
||||
test('activation preview uses the same company licence context and functional intersection as runtime', function () {
|
||||
DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false, 'work_context_policy' => json_encode(['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']], JSON_THROW_ON_ERROR)]);
|
||||
config(['work_context.entitlements.'.$this->admin->tenant_id => ['procurement.internal']]);
|
||||
$profile = activationPreviewProfile($this->admin->tenant_id, 'Preparação nas áreas autorizadas', ['requester', 'people_service_prepare'], ['procurement.internal', 'people.internal']);
|
||||
activationPreviewMember($this->admin->tenant_id, $this->f['receiver']->id, $profile);
|
||||
$preview = app(OrganizationActivationPreview::class)->build($this->admin);
|
||||
$rows = collect($preview['users'])->keyBy('id');
|
||||
expect($rows[$this->f['receiver']->id]['configured_contexts'])->toBe(['procurement.internal']);
|
||||
$this->post($this->path.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação sintética respeitando os contextos contratados.'])->assertSessionHasNoErrors();
|
||||
expect(app(WorkContext::class)->available($this->f['receiver']))->toBe($rows[$this->f['receiver']->id]['configured_contexts']);
|
||||
$this->actingAs($this->f['receiver'])->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('activation preview cannot be read by warehouse users or expose another company configuration', function () {
|
||||
$this->actingAs($this->f['receiver'])->get($this->path)->assertForbidden();
|
||||
$tenant = Tenant::query()->create(['name' => 'Outra organização sintética']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($foreign)->get($this->path)->assertInertia(fn (Assert $page) => $page->has('activationPreview.users', 1)
|
||||
->where('activationPreview.users.0.id', $foreign->id)->where('activationPreview.permanent_administrators', 0));
|
||||
});
|
||||
|
||||
test('activation preview is informational and cannot replace confirmation or an up to date version', function () {
|
||||
DB::table('tenants')->where('id', $this->admin->tenant_id)->update(['rules_provisional' => false]);
|
||||
$preview = app(OrganizationActivationPreview::class)->build($this->admin);
|
||||
$this->postJson($this->path.'/activate', ['version' => 1, 'reason' => 'Não activar pela mera leitura do diagnóstico.'])->assertUnprocessable()->assertJsonValidationErrors('confirm');
|
||||
app(OrganizationConfiguration::class)->execute($this->admin, 'profile', ['version' => 1, 'name' => 'Consulta sintética', 'permissions' => ['warehouse_request_read']]);
|
||||
$this->postJson($this->path.'/activate', ['version' => $preview['version'], 'confirm' => true, 'reason' => 'Uma versão anterior não confirma alterações posteriores.'])->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
expect(app(OrganizationAccess::class)->active($this->admin->tenant_id))->toBeFalse();
|
||||
$this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('activationPreview.version', 2));
|
||||
});
|
||||
@@ -151,8 +151,8 @@ test('imported ledger is append-only and disabling the history cannot silently r
|
||||
$proposal = prepareOriginalHistory($this);
|
||||
$this->actingAs($this->f['validator']);
|
||||
app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history', $proposal['proposal_id'], 'Preservar responsabilidade única', (string) Str::uuid());
|
||||
expect(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0]))->toThrow(QueryException::class);
|
||||
expect(fn () => DB::table('pa_imported_payments')->delete())->toThrow(QueryException::class);
|
||||
expect(fn () => DB::transaction(fn () => DB::table('pa_history_imports')->update(['confirmed_minor' => 0])))->toThrow(QueryException::class);
|
||||
expect(fn () => DB::transaction(fn () => DB::table('pa_imported_payments')->delete()))->toThrow(QueryException::class);
|
||||
config(['common-payables.history_enabled' => false]);
|
||||
expect(fn () => app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toThrow(HttpException::class);
|
||||
config(['common-payables.enabled' => false]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
@@ -107,3 +108,16 @@ test('a full confirmation leaves zero balance and prevents another confirmation'
|
||||
expect(app(SupplierPayables::class)->balance($this->f['invoice']))->toBe(0);
|
||||
expect(fn () => $workflow->review($this->f['validator'], $this->payment, 'confirmed', 'Saldo já liquidado'))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('task inbox delegates imported payment authority without exposing or deciding the ledger', function () {
|
||||
$before = [DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), $this->frozen];
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2));
|
||||
config(['common-payables.history_decisions_enabled' => false]);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
config(['common-payables.history_decisions_enabled' => true]);
|
||||
DB::table('pa_access')->where('user_id', $this->f['validator']->id)->where('action', 'validate')->delete();
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
config(['common-payables.source_instance' => 'different-installation']);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect([DB::table('pa_payment_decisions')->count(), DB::table('audit_logs')->count(), Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'payments' => DB::table('pa_imported_payments')->get()->toArray()])])->toEqual($before);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/PaymentFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = paymentFixture();
|
||||
$this->actingAs($this->f['author']);
|
||||
$this->payment = app(ExternalPaymentWorkflow::class)->register($this->f['author'], $this->f['invoice'], $this->f['payment_data']);
|
||||
});
|
||||
|
||||
test('payment task routes the independent reviewer to the exact record without exposing evidence in the task', function () {
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['validator']);
|
||||
$before = [DB::table('external_payments')->get()->toArray(), DB::table('audit_logs')->count(), BudgetConsumption::query()->sum('amount_minor')];
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', '/pagamentos?payment_id='.$this->payment->id.'#payment-'.$this->payment->id)
|
||||
->missing('tasks.data.0.external_reference')->missing('tasks.data.0.evidence_reference')->missing('tasks.data.0.bank_snapshot'));
|
||||
expect([DB::table('external_payments')->get()->toArray(), DB::table('audit_logs')->count(), BudgetConsumption::query()->sum('amount_minor')])->toEqual($before);
|
||||
$this->get('/pagamentos?payment_id='.$this->payment->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
||||
->assertInertia(fn (Assert $page) => $page->where('focusPaymentId', $this->payment->id)->where('payments.total', 1)->where('payments.data.0.canReview', true));
|
||||
$this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Conferência independente sintética', 'independently_verified' => true])->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000)
|
||||
->and(BudgetConsumption::query()->sum('amount_minor'))->toEqual($before[2]);
|
||||
});
|
||||
|
||||
test('payment focus stays scoped to its invoice and process documents link to its exact record', function () {
|
||||
$this->get('/pagamentos?invoice_id='.$this->f['invoice']->id)->assertInertia(fn (Assert $page) => $page->where('focusInvoiceId', $this->f['invoice']->id)->has('invoices', 1)->where('payments.total', 1));
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page
|
||||
->where('stages.7.href', '/facturas?purchase_order_id='.$this->f['order']->id)
|
||||
->where('documents', fn ($documents) => collect($documents)->contains('href', '/pagamentos?payment_id='.$this->payment->id.'#payment-'.$this->payment->id)));
|
||||
});
|
||||
|
||||
test('payment task and review form respect conflicts and revoked finance authority', function () {
|
||||
$this->actingAs($this->f['validator']);
|
||||
$guard = Mockery::mock(ConflictGuard::class)->makePartial();
|
||||
$guard->shouldReceive('processBlocked')->andReturn(true);
|
||||
app()->instance(ConflictGuard::class, $guard);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('payments.data.0.canReview', false));
|
||||
app()->forgetInstance(ConflictGuard::class);
|
||||
$this->f['validator']->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$this->actingAs($this->f['validator']->fresh());
|
||||
$this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('payments.data.0.canReview', false)->where('payments.data.0.external_reference', null));
|
||||
$this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
|
||||
});
|
||||
|
||||
test('bank revision does not hide a pending rejection but confirmation still rechecks the bank', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'account_identifier' => 'TEST-NEW-VERSION']);
|
||||
$this->actingAs($this->f['validator']);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$data = ['decision' => 'confirmed', 'reason' => 'Conferência sintética', 'independently_verified' => true];
|
||||
$this->postJson('/pagamentos/'.$this->payment->id.'/conferir', $data)->assertUnprocessable();
|
||||
expect($this->payment->fresh()->status)->toBe('pending');
|
||||
$this->post('/pagamentos/'.$this->payment->id.'/conferir', [...$data, 'decision' => 'rejected'])->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('payment and invoice focus cannot cross companies', function () {
|
||||
$other = invoiceFixture();
|
||||
$foreign = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($foreign)->getJson('/pagamentos?payment_id='.$this->payment->id)->assertUnprocessable();
|
||||
$this->getJson('/pagamentos?invoice_id='.$this->f['invoice']->id)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'invoice:'.$other['invoice']->id));
|
||||
});
|
||||
|
||||
test('maintenance finance can consult payment without receiving evidence or decision authority', function () {
|
||||
$actor = $this->f['validator'];
|
||||
supplyGrant($actor, ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($actor);
|
||||
$this->get('/requisicoes/tarefas?module=finance')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/pagamentos')->assertInertia(fn (Assert $page) => $page->where('canManage', false)->where('payments.data.0.canReview', false)->where('payments.data.0.evidence_reference', null));
|
||||
$this->post('/pagamentos/'.$this->payment->id.'/conferir', ['decision' => 'rejected', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
|
||||
expect($this->payment->fresh()->status)->toBe('pending');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Department;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationActivationPreview;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('synthetic pilot access proposal preserves existing authority while activating only its explicit task additions in a disposable database', function () {
|
||||
$connection = DB::connection();
|
||||
$isolated = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:')
|
||||
|| ($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $connection->getDatabaseName()));
|
||||
if (! app()->environment('testing') || ! $isolated) {
|
||||
throw new LogicException('Proposta proibida fora de ensaio descartável.');
|
||||
}
|
||||
$proposal = json_decode(file_get_contents(base_path('docs/proofs/pilot-access-proposal.json')), true, 64, JSON_THROW_ON_ERROR);
|
||||
expect($proposal['status'])->toBe('pending_owner_authorization')->and($proposal['environment'])->toBe('http://127.0.0.1:8005');
|
||||
$company = Tenant::query()->create(['name' => 'Simulação descartável dos acessos do piloto',
|
||||
'work_context_policy' => ['revision' => 1, 'enabled' => $proposal['enabled_contexts']]]);
|
||||
$department = Department::query()->create(['tenant_id' => $company->id, 'name' => 'Departamento sintético']);
|
||||
config(['work_context.entitlements.'.$company->id => $proposal['enabled_contexts']]);
|
||||
$users = [];
|
||||
$before = [];
|
||||
foreach ($proposal['users'] as $person) {
|
||||
$user = User::factory()->create(['tenant_id' => $company->id, 'department_id' => $department->id,
|
||||
'name' => 'Simulação — '.$person['name'], 'purchasing_role' => PurchasingRole::from($person['current_permissions'][0])]);
|
||||
$users[$person['id']] = $user;
|
||||
$before[$person['id']] = ['permissions' => app(OrganizationAccess::class)->permissions($user),
|
||||
'contexts' => app(WorkContext::class)->available($user), 'requester' => app(OrganizationAccess::class)->canRequest($user),
|
||||
'department_scoped' => app(OrganizationAccess::class)->departmentScoped($user)];
|
||||
expect($before[$person['id']]['contexts'])->toBe($person['current_contexts']);
|
||||
}
|
||||
$actor = $users[8];
|
||||
$this->actingAs($actor);
|
||||
$base = '/administracao/parametrizacao';
|
||||
DB::table('organization_access')->insert(['tenant_id' => $company->id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$version = 1;
|
||||
$profiles = [];
|
||||
foreach ($proposal['profiles'] as $profile) {
|
||||
$this->postJson($base.'/profile', ['version' => $version++, 'name' => $profile['name'], 'permissions' => $profile['permissions'], 'work_contexts' => $profile['work_contexts']])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$profiles[$profile['key']] = DB::table('organization_profiles')->where('name', $profile['name'])->sole()->id;
|
||||
}
|
||||
foreach ($proposal['users'] as $person) {
|
||||
$ids = array_map(fn ($key) => $profiles[$key], $person['profile_keys']);
|
||||
$this->postJson($base.'/assign', ['version' => $version++, 'user_id' => $users[$person['id']]->id, 'profile_ids' => $ids,
|
||||
'maintenance' => false, 'expires_at' => null, 'reason' => 'Ensaiar proposta sintética apenas na base descartável.'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
}
|
||||
$rules = $proposal['proposed_rules'];
|
||||
$this->postJson($base.'/rules', ['version' => $version++, 'first_limit' => $rules['first_limit'], 'second_limit' => $rules['second_limit'],
|
||||
'includes_tax' => $rules['includes_tax'], 'reason' => 'Conferência sintética das mesmas alçadas, sem alterar os valores.'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->postJson($base.'/work_contexts', ['version' => $version++, 'enabled' => $proposal['enabled_contexts'], 'confirm' => true,
|
||||
'reason' => 'Preservar as opções existentes apenas na simulação descartável.'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$preview = app(OrganizationActivationPreview::class)->build($actor);
|
||||
expect($preview['can_activate'])->toBeTrue()->and($preview['losing_access'])->toBe(0)->and($preview['permanent_administrators'])->toBe(2);
|
||||
foreach ($users as $sourceId => $user) {
|
||||
expect(app(OrganizationAccess::class)->permissions($user))->toBe($before[$sourceId]['permissions']);
|
||||
}
|
||||
$this->postJson($base.'/activate', ['version' => $version, 'confirm' => true, 'reason' => 'Activar a proposta exclusivamente no ensaio descartável.'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
foreach ($proposal['users'] as $person) {
|
||||
$user = $users[$person['id']];
|
||||
$permissions = app(OrganizationAccess::class)->permissions($user);
|
||||
expect($permissions)->toEqualCanonicalizing($person['planned_permissions'])
|
||||
->and(array_values(array_diff($person['current_permissions'], $permissions)))->toBe([])
|
||||
->and(app(WorkContext::class)->available($user))->toBe($before[$person['id']]['contexts'])
|
||||
->and(app(OrganizationAccess::class)->canRequest($user))->toBe($before[$person['id']]['requester'])
|
||||
->and(app(OrganizationAccess::class)->departmentScoped($user))->toBe($before[$person['id']]['department_scoped']);
|
||||
}
|
||||
$this->get($base)->assertInertia(fn (Assert $page) => $page->where('activationPreview.active', true)->where('activationPreview.losing_access', 0));
|
||||
expect(DB::table('users')->count())->toBe(12)->and(DB::table('requisitions')->count())->toBe(0)
|
||||
->and(DB::table('purchase_orders')->count())->toBe(0)->and(DB::table('invoices')->count())->toBe(0)
|
||||
->and(DB::table('pa_access')->count())->toBe(0);
|
||||
});
|
||||
@@ -45,6 +45,8 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
function awardPrices(object $test, int $firstPrice, int $taxRate = 0): void
|
||||
{
|
||||
$test->r->items()->update(['tax_rate' => $taxRate]);
|
||||
@@ -776,3 +778,65 @@ test('legacy award keeps null admission basis and refuses later adoption', funct
|
||||
expect(AdmissionAdoption::query()->count())->toBe(0);
|
||||
issuePurchaseOrder($this, $award);
|
||||
});
|
||||
|
||||
test('task inbox follows every final award approval and hands approved issuance back to procurement', function () {
|
||||
awardPrices($this, 30000000);
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
foreach ([$this->head, $this->secondBuyer, $this->finance] as $reviewer) {
|
||||
$this->actingAs($reviewer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'award_approve')->where('tasks.data.0.href', $this->awardUrl));
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
}
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'order_issue_review'));
|
||||
$this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canIssue', true));
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
|
||||
expect(PurchaseOrder::query()->count())->toBe(1);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('task inbox respects pending cancellation and consultation leaves award budget and audit untouched', function () {
|
||||
enablePurchaseCancellation($this);
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/cancelamento', ['reason' => 'Necessidade fictícia em conferência'])->assertSessionHasNoErrors();
|
||||
$before = [$award->fresh()->toArray(), $award->approvals()->get()->toArray(), BudgetEntry::query()->get()->toArray(), AuditLog::query()->count()];
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canDecide', false));
|
||||
expect([$award->fresh()->toArray(), $award->approvals()->get()->toArray(), BudgetEntry::query()->get()->toArray(), AuditLog::query()->count()])->toEqual($before);
|
||||
$cancel = AwardCancellation::query()->sole();
|
||||
$this->actingAs($this->finance)->post('/cancelamentos/'.$cancel->id.'/decidir', ['decision' => 'rejected', 'reason' => 'Manter o processo sintético original'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
});
|
||||
|
||||
test('task inbox cannot grant an award decision to maintenance or a revoked department role', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
supplyGrant($this->head, ['department_head']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->awardUrl)->assertInertia(fn (Assert $page) => $page->where('canDecide', false));
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($this->head->fresh())->get('/requisicoes/tarefas?module=purchases')->assertForbidden();
|
||||
expect($award->fresh()->status)->toBe('pending')->and(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('task inbox issuance review does not override a proposal that expired after approval', function () {
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
$this->quotes[0]->update(['valid_until' => today()->subDay()]);
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'order_issue_review'));
|
||||
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors();
|
||||
expect(PurchaseOrder::query()->count())->toBe(0)->and($award->fresh()->status)->toBe('approved');
|
||||
});
|
||||
|
||||
test('quotation tasks hand a ready comparison to adjudication without reopening rejected awards', function () {
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'comparison_review'));
|
||||
$award = selectPurchaseAward($this);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'award_approve'));
|
||||
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'rejected', 'comment' => 'Devolver ao percurso autorizado de revisão.'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('canCreate', false)->where('canEvaluate', false));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\{ConflictGuard,ReceiptStockMovement};
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ReceiptAdapterReplayTest extends TestCase
|
||||
{
|
||||
public function test_final_adapter_reuses_existing_movement_without_a_second_effect(): void
|
||||
{
|
||||
$i=DB::selectOne("SELECT current_database() AS name,current_user AS role,current_setting('cluster_name') AS cluster");
|
||||
$this->assertMatchesRegularExpression('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name);
|
||||
$this->assertSame('stock_receipt_qa_'.substr($i->name,-12),$i->role);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
|
||||
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);
|
||||
$proof=json_decode(file_get_contents($root.'/receipt-results.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
$row=DB::table('stock_movements')->where('tenant_id',$proof['link']['tenant'])->where('id',$proof['link']['movement'])->first();$this->assertNotNull($row);
|
||||
$actor=User::findOrFail($row->moved_by);$this->actingAs($actor);$count=DB::table('stock_movements')->count();
|
||||
$hash=json_decode($row->snapshot,true)['inventory_source_hash'];
|
||||
$result=DB::transaction(function()use($actor,$row,$hash){app(ConflictGuard::class)->lockCompany($actor);return app(ReceiptStockMovement::class)->for($actor)->write($row->goods_receipt_item_id,$hash);});
|
||||
$this->assertTrue($result['replayed']);$this->assertSame((int)$row->id,$result['movement_id']);$this->assertSame($count,DB::table('stock_movements')->count());
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@ use App\Models\User;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
function supplierPayload(string $nuit = '123456789'): array
|
||||
{
|
||||
return ['name' => 'Fornecedor de teste', 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'test@example.com', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário teste', 'document_references' => 'Alvará e declaração verificados no processo 123.', 'diligence_notes' => 'Documentação conferida e sem impedimentos.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
||||
@@ -149,7 +152,7 @@ test('configured comparison uses the preserved minimum and weights and retains i
|
||||
$this->tenant->update(['comparison_policy' => ComparisonPolicy::DEFAULTS]);
|
||||
expect(app(ProposalComparison::class)->inspect($this->r)['fingerprint'])->toBe($before['fingerprint']);
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->where('comparisonPolicy.minimum_evaluators', 1)->where('ready', true));
|
||||
$this->owner->update(['purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->owner)->post("/cotacoes/{$quote->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'O solicitante continua impedido.', 'no_conflict' => true])->assertForbidden();
|
||||
});
|
||||
|
||||
@@ -178,3 +181,131 @@ test('supplier quote and evaluation routes isolate companies', function () {
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertNotFound();
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Tentativa de outro inquilino.', 'no_conflict' => true])->assertNotFound();
|
||||
});
|
||||
|
||||
function quotationTaskRegister(object $test): Quotation
|
||||
{
|
||||
$test->actingAs($test->buyer)->post('/requisicoes/'.$test->r->id.'/cotacoes', $test->quote)->assertSessionHasNoErrors();
|
||||
|
||||
return Quotation::query()->sole();
|
||||
}
|
||||
|
||||
function quotationTaskEvaluation(): array
|
||||
{
|
||||
return ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação sintética fundamentada.', 'no_conflict' => true];
|
||||
}
|
||||
|
||||
test('quotation tasks link missing proposals and each independent evaluation to the existing forms', function () {
|
||||
$path = '/requisicoes/'.$this->r->id.'/cotacoes';
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_prepare'));
|
||||
$this->get($path.'?prepare=1')->assertInertia(fn (Assert $page) => $page->where('prepare', true)->where('canCreate', true)->where('requisition.items.0.id', $this->item->id));
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id)->missing('tasks.data.0.total_minor')->missing('tasks.data.0.evaluations'));
|
||||
$this->get($path.'?quotation_id='.$quote->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
||||
->assertInertia(fn (Assert $page) => $page->where('focusQuotationId', $quote->id)->where('quotations.0.canEvaluate', true));
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2));
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_prepare'));
|
||||
});
|
||||
|
||||
test('quotation tasks use preserved comparison minima and hand the ready result to procurement', function () {
|
||||
$policy = [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1, 'minimum_evaluators' => 1];
|
||||
$this->r->update(['comparison_policy' => $policy]);
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->actingAs($this->finance)->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
|
||||
$this->tenant->update(['comparison_policy' => [...$policy, 'revision' => 2, 'minimum_proposals' => 5, 'minimum_evaluators' => 5]]);
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'comparison_review')->where('tasks.data.0.href', '/requisicoes/'.$this->r->id.'/adjudicacao'));
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(DB::table('purchase_awards')->count())->toBe(0)->and(DB::table('purchase_orders')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('quotation tasks retain comparison impediments without asking for replacement data', function () {
|
||||
$this->r->update(['comparison_policy' => [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1]]);
|
||||
$quote = quotationTaskRegister($this);
|
||||
$quote->update(['valid_until' => today()->subDay()]);
|
||||
$before = [DB::table('quotations')->get()->toArray(), DB::table('proposal_evaluations')->count(), DB::table('audit_logs')->count()];
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'quotation_check')->where('tasks.data.0.href', '/requisicoes/'.$this->r->id.'/cotacoes'));
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$quote->id)->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false));
|
||||
expect([DB::table('quotations')->get()->toArray(), DB::table('proposal_evaluations')->count(), DB::table('audit_logs')->count()])->toEqual($before);
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasErrors('evaluation');
|
||||
});
|
||||
|
||||
test('quotation tasks respect requester independence department scope and quotation focus ownership', function () {
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->owner)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertOk()->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false));
|
||||
$department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outra unidade fictícia']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertNotFound();
|
||||
$other = $this->r->replicate();
|
||||
$other->number = 'REQ-OUTRA-ORIGEM';
|
||||
$other->save();
|
||||
$alien = $quote->replicate();
|
||||
$alien->requisition_id = $other->id;
|
||||
$alien->save();
|
||||
$this->actingAs($this->finance)->getJson('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$alien->id)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('quotation tasks never keep stale finance or procurement authority during a command', function () {
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->actingAs($this->finance);
|
||||
DB::table('users')->where('id', $this->finance->id)->update(['purchasing_role' => 'warehouse']);
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertForbidden();
|
||||
$this->actingAs($this->buyer);
|
||||
DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'warehouse']);
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'reference' => 'NOVA-TENTATIVA'])->assertForbidden();
|
||||
expect(Quotation::query()->count())->toBe(1)->and(DB::table('proposal_evaluations')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('quotation tasks exclude maintenance from preparation and evaluation', function (string $function) {
|
||||
quotationTaskRegister($this);
|
||||
$actor = $function === 'procurement' ? $this->buyer : $this->finance;
|
||||
supplyGrant($actor, [$function]);
|
||||
DB::table('organization_memberships')->where('user_id', $actor->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($actor)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('canCreate', false)->where('canEvaluate', false)->where('quotations.0.canEvaluate', false));
|
||||
})->with(['procurement', 'finance']);
|
||||
|
||||
test('quotation tasks keep older market consultations visible through pagination and search', function () {
|
||||
$this->r->forceFill(['created_at' => now()->subMonths(2)])->save();
|
||||
for ($index = 1; $index < 30; $index++) {
|
||||
$request = $this->r->replicate();
|
||||
$request->number = 'REQ-NEW-'.$index;
|
||||
$request->created_at = now()->subDay();
|
||||
$request->save();
|
||||
}
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 25)->where('tasks.data.0.reference', $this->r->number));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&page=2')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 5));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=REQ-TEST')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.reference', $this->r->number));
|
||||
});
|
||||
|
||||
test('quotation tasks and forms immediately respect a declared participation conflict', function () {
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->post('/conflitos', ['scope' => 'process_supplier', 'supplier_id' => $this->supplier->id, 'requisition_id' => $this->r->id,
|
||||
'has_conflict' => true, 'known_since' => today()->toDateString(), 'reason' => 'Relação sintética que impede a participação.'])->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes?quotation_id='.$quote->id)->assertInertia(fn (Assert $page) => $page->where('quotations.0.canEvaluate', false)->where('quotations.0.conflict_blocked', true));
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasErrors('conflict');
|
||||
expect(DB::table('proposal_evaluations')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('quotation tasks recheck qualified evaluator authority without replacing the stored evaluation', function () {
|
||||
$this->r->update(['comparison_policy' => [...ComparisonPolicy::DEFAULTS, 'revision' => 1, 'configured' => true, 'minimum_proposals' => 1, 'minimum_evaluators' => 1]]);
|
||||
$quote = quotationTaskRegister($this);
|
||||
$this->actingAs($this->finance)->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
|
||||
$history = DB::table('proposal_evaluations')->get()->toArray();
|
||||
DB::table('users')->where('id', $this->finance->id)->update(['purchasing_role' => 'warehouse']);
|
||||
$this->actingAs($this->buyer)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'quotation_evaluate'));
|
||||
expect(DB::table('proposal_evaluations')->get()->toArray())->toEqual($history);
|
||||
$this->post('/cotacoes/'.$quote->id.'/avaliar', quotationTaskEvaluation())->assertSessionHasNoErrors();
|
||||
expect(DB::table('proposal_evaluations')->count())->toBe(2);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'comparison_review'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\WarehouseExchange;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
require_once __DIR__.'/../Support/InboxFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
supplyGrant($this->reviewer, ['warehouse_request_review']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
|
||||
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
|
||||
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]]);
|
||||
});
|
||||
|
||||
function exchangeCommand($test, $actor, string $action, array $extra = []): WarehouseRequestEvent
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return app(WarehouseFulfilment::class)->command($actor, $test->r->id, [
|
||||
'operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'],
|
||||
'action' => $action, 'reason' => 'Intervenção sintética do ensaio de integração.', ...$extra]);
|
||||
}
|
||||
|
||||
function exchangeDeliver($test, string $quantity = '1.500'): WarehouseRequestEvent
|
||||
{
|
||||
exchangeCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
exchangeCommand($test, $test->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
|
||||
return exchangeCommand($test, $test->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => $quantity]]]);
|
||||
}
|
||||
|
||||
test('warehouse exchange publishes only physical facts with original identifiers and stable replay content', function () {
|
||||
$url = '/stock/integracao/entregas';
|
||||
$this->getJson($url)->assertOk()->assertJsonCount(0, 'items');
|
||||
$delivery = exchangeDeliver($this);
|
||||
$first = $this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
||||
->assertJsonPath('schema', WarehouseExchange::SCHEMA)->assertJsonCount(1, 'items')
|
||||
->assertJsonPath('items.0.lines.0.quantity_milli', 1500)
|
||||
->assertJsonPath('items.0.lines.0.stock_operation_id', $delivery->data['lines'][0]['stock_operation_id'])
|
||||
->assertJsonPath('items.0.request.document_type', 'internal_warehouse_request')
|
||||
->assertJsonPath('items.0.request.destination', 'Oficina interna')->json('items.0');
|
||||
$payload = $first;
|
||||
unset($payload['payload_sha256']);
|
||||
expect(hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)))->toBe($first['payload_sha256']);
|
||||
// Current register changes must not rewrite the original item or requester.
|
||||
$this->f['stock']->update(['name' => 'Nome posterior do cadastro']);
|
||||
$this->f['issuer']->update(['name' => 'Nome posterior da pessoa']);
|
||||
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
$response = $this->getJson($url)->assertOk()->assertJsonCount(2, 'items');
|
||||
expect($response->json('items.0'))->toBe($first);
|
||||
$response->assertJsonPath('items.1.action', 'receive')->assertJsonCount(0, 'items.1.lines')
|
||||
->assertJsonPath('items.1.delivery_event_key', $first['idempotency_key']);
|
||||
});
|
||||
|
||||
test('warehouse exchange resumes old pending pages and later polls without skipping or duplicating facts', function () {
|
||||
$delivery = exchangeDeliver($this);
|
||||
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->assertJsonPath('has_more', true)->json();
|
||||
$next = '/stock/integracao/entregas?limit=1&cursor='.urlencode($first['next_cursor']);
|
||||
exchangeCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$second = $this->getJson($next)->assertOk()->assertJsonPath('items.0.action', 'receive')->assertJsonPath('has_more', true)->json();
|
||||
expect($second['items'][0]['event_id'])->toBeGreaterThan($first['items'][0]['event_id']);
|
||||
$third = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($second['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('has_more', false)->json();
|
||||
$empty = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($third['next_cursor']))->assertOk()->assertJsonCount(0, 'items')->json();
|
||||
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->firstOrFail();
|
||||
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
||||
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($empty['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.action', 'receive');
|
||||
});
|
||||
|
||||
test('warehouse exchange reads leave business ledgers profiles and audit unchanged', function () {
|
||||
exchangeDeliver($this);
|
||||
$tables = ['stock_movements', 'stock_operations', 'warehouse_reservations', 'warehouse_requests', 'warehouse_request_events', 'audit_logs', 'requisitions', 'organization_memberships'];
|
||||
$before = [];
|
||||
foreach ($tables as $table) {
|
||||
$before[$table] = DB::table($table)->get()->toArray();
|
||||
}
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk();
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk();
|
||||
foreach ($tables as $table) {
|
||||
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
|
||||
}
|
||||
});
|
||||
|
||||
test('warehouse exchange rejects corrupt cross user cursors invalid limits and revoked access', function () {
|
||||
exchangeDeliver($this);
|
||||
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
||||
$this->getJson('/stock/integracao/entregas?cursor=invalid')->assertUnprocessable()->assertJsonValidationErrors('cursor');
|
||||
$this->getJson('/stock/integracao/entregas?limit=101')->assertUnprocessable();
|
||||
$this->getJson('/stock/integracao/entregas?limit=0')->assertUnprocessable();
|
||||
$this->actingAs($this->f['issuer'])->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->delete();
|
||||
$this->getJson('/stock/integracao/entregas')->assertForbidden();
|
||||
});
|
||||
|
||||
test('warehouse exchange respects department ownership and tenant boundaries', function () {
|
||||
exchangeDeliver($this);
|
||||
$department = Department::create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
|
||||
supplyGrant($outsider, ['warehouse_request_review', 'warehouse_request_prepare']);
|
||||
$this->actingAs($outsider)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
||||
$this->actingAs($this->reviewer)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items');
|
||||
$foreign = receiptFixture('50.000');
|
||||
supplyGrant($foreign['issuer'], ['admin']);
|
||||
$this->actingAs($foreign['issuer'])->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
||||
});
|
||||
|
||||
test('warehouse exchange requires an authenticated account with functional access', function () {
|
||||
auth('web')->logout();
|
||||
$this->getJson('/stock/integracao/entregas')->assertUnauthorized();
|
||||
$this->actingAs($this->f['otherReceiver'])->getJson('/stock/integracao/entregas')->assertForbidden();
|
||||
});
|
||||
|
||||
test('warehouse exchange rejects old cursors when functional scope expands and publishes no mere reservation', function () {
|
||||
exchangeCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
exchangeCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
$response = $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
||||
$cursor = $response->json('next_cursor');
|
||||
supplyGrant($this->reviewer, ['warehouse_request_read']);
|
||||
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable()->assertJsonValidationErrors('cursor');
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
||||
});
|
||||
|
||||
test('warehouse exchange never presents a cancelled undelivered balance as another physical issue', function () {
|
||||
$delivery = exchangeDeliver($this);
|
||||
exchangeCommand($this, $this->reviewer, 'cancel');
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.event_id', $delivery->id);
|
||||
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(2, 'items')->assertJsonPath('items.1.action', 'receive');
|
||||
});
|
||||
|
||||
test('warehouse exchange refuses another context and invalidates cursors after context activation', function () {
|
||||
exchangeDeliver($this);
|
||||
$actor = $this->f['issuer'];
|
||||
$this->actingAs($actor);
|
||||
$legacyCursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
||||
$this->f['tenant']->update(['rules_provisional' => false, 'work_context_policy' => ['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']]]);
|
||||
config(['work_context.entitlements.'.$actor->tenant_id => ['procurement.internal', 'people.internal']]);
|
||||
supplyGrant($actor, ['admin']);
|
||||
DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['procurement.internal', 'people.internal'])]);
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
|
||||
$this->getJson('/stock/integracao/entregas')->assertForbidden();
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
||||
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($legacyCursor))->assertUnprocessable();
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')
|
||||
->assertJsonPath('items.0.request.work_context_key', 'procurement.internal');
|
||||
});
|
||||
|
||||
test('warehouse exchange preserves the equipment selected at the request origin', function () {
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
|
||||
'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério sintético.', 'is_critical' => false, 'created_at' => now()]);
|
||||
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
|
||||
'department_id' => $this->f['issuer']->department_id, 'criticality_level_id' => $level->id,
|
||||
'code' => 'EQ-1', 'description' => 'Máquina da oficina', 'area' => 'Oficina', 'status' => 'active']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['operation_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
|
||||
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal',
|
||||
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'equipment', 'equipment_id' => $equipment->id]]]);
|
||||
exchangeDeliver($this, '4.000');
|
||||
$equipment->update(['description' => 'Descrição futura']);
|
||||
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonPath('items.0.lines.0.equipment.id', $equipment->id)
|
||||
->assertJsonPath('items.0.lines.0.equipment.description', 'Máquina da oficina')->assertJsonPath('items.0.lines.0.use', 'equipment');
|
||||
});
|
||||
|
||||
test('warehouse delivery feed is accepted by an independent durable receiver without another stock movement', function () {
|
||||
$delivery = exchangeDeliver($this);
|
||||
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
$before = DB::table('stock_movements')->count();
|
||||
$db = inboxDatabase();
|
||||
$inbox = new WarehouseInbox($db, 'synthetic-source', $this->f['tenant']->id, 99, 'synthetic-consumer');
|
||||
$checkpoint = $inbox->checkpoint();
|
||||
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->json();
|
||||
$accepted = $inbox->accept($first, $checkpoint['version'], $checkpoint['cursor']);
|
||||
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeTrue();
|
||||
$checkpoint = $inbox->checkpoint();
|
||||
$second = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($checkpoint['cursor']))->assertOk()->json();
|
||||
$accepted = $inbox->accept($second, $checkpoint['version'], $checkpoint['cursor']);
|
||||
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeFalse()
|
||||
->and($inbox->accept($second, $checkpoint['version'], $checkpoint['cursor'])['replayed'])->toBeTrue()
|
||||
->and($inbox->fact($first['items'][0]['idempotency_key']))->toBe($first['items'][0])
|
||||
->and(DB::table('stock_movements')->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('warehouse feed cursor stays bounded even for a profile containing the whole permission catalogue', function () {
|
||||
supplyGrant($this->f['issuer'], array_keys(OrganizationAccess::PERMISSIONS));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
||||
expect(strlen($cursor))->toBeLessThanOrEqual(4096);
|
||||
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertOk()->assertJsonCount(0, 'items');
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use App\Services\WarehouseReservations;
|
||||
use App\Services\WarehouseWorkdesk;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
@@ -64,38 +72,41 @@ test('reservations protect stock from unrelated issues and cancellation releases
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$issue=['kind'=>'issue','reference'=>'UNRELATED','idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$this->f['issuer']->id,'warehouse_id'=>$this->f['warehouse']->id,'stock_item_id'=>$this->f['stock']->id,'quantity'=>'7.000','destination'=>'Outra oficina','reason'=>'Tentativa de saída que consumiria reserva.'];
|
||||
expect(fn()=>app(StockWorkflow::class)->record($this->f['receiver'],$issue))->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
warehouseCommand($this,$this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'1.500']]]);
|
||||
$issue = ['kind' => 'issue', 'reference' => 'UNRELATED', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['issuer']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '7.000', 'destination' => 'Outra oficina', 'reason' => 'Tentativa de saída que consumiria reserva.'];
|
||||
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $issue))->toThrow(ValidationException::class);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([2500]);
|
||||
$cancel=warehouseCommand($this,$this->reviewer,'cancel');
|
||||
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',$cancel)->assertRedirect();
|
||||
$p=app(WarehouseFulfilment::class)->progress($this->r);
|
||||
$cancel = warehouseCommand($this, $this->reviewer, 'cancel');
|
||||
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $cancel)->assertRedirect();
|
||||
$p = app(WarehouseFulfilment::class)->progress($this->r);
|
||||
expect($p['state'])->toBe('cancelled')->and($p['reserved'])->toBe([0])->and($p['cancelled'])->toBe([2500])->and($p['remaining'])->toBe([0]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(StockWorkflow::class)->record($this->f['receiver'],$issue);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(1500);
|
||||
app(StockWorkflow::class)->record($this->f['receiver'], $issue);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(1500);
|
||||
});
|
||||
|
||||
test('competing integral authorizations do not overbook and partial reservations can be replenished explicitly', function () {
|
||||
warehouseCommand($this,$this->f['issuer'],'submit');
|
||||
warehouseCommand($this,$this->reviewer,'authorize');
|
||||
$original=$this->r;
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
$original = $this->r;
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$data=$this->draftData;$data['operation_key']=(string)Str::uuid();$data['lines'][0]['quantity']='8.000';
|
||||
$this->r=app(WarehouseRequests::class)->draft($this->f['issuer'],$data);
|
||||
warehouseCommand($this,$this->f['issuer'],'submit',['partial_allowed'=>true]);
|
||||
$this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',['operation_key'=>(string)Str::uuid(),'version'=>1,'action'=>'authorize','reason'=>'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
|
||||
$data = $this->draftData;
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$data['lines'][0]['quantity'] = '8.000';
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
$this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('submitted');
|
||||
warehouseCommand($this,$this->reviewer,'authorize',['partial_allowed'=>true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([6000]);
|
||||
$second=$this->r;$this->r=$original;
|
||||
warehouseCommand($this,$this->reviewer,'cancel');
|
||||
$this->r=$second;
|
||||
warehouseCommand($this,$this->reviewer,'reserve');
|
||||
$second = $this->r;
|
||||
$this->r = $original;
|
||||
warehouseCommand($this, $this->reviewer, 'cancel');
|
||||
$this->r = $second;
|
||||
warehouseCommand($this, $this->reviewer, 'reserve');
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([8000]);
|
||||
expect(fn()=>DB::table('warehouse_reservations')->where('warehouse_request_id',$second->id)->update(['quantity_milli'=>0]))->toThrow(\Illuminate\Database\QueryException::class);
|
||||
expect(fn () => DB::table('warehouse_reservations')->where('warehouse_request_id', $second->id)->update(['quantity_milli' => 0]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
|
||||
@@ -112,6 +123,28 @@ test('warehouse authorization rejects self decision stale version and unauthoriz
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('quality hold can block already reserved material without releasing the commitment or permitting physical delivery', function () {
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$setup = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'Q-RES', 'expires_on' => today()->addMonth()->toDateString()]]], 'prepare'));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $setup));
|
||||
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
|
||||
$metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', $metadata));
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'][0];
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'hold', $metadata));
|
||||
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa de entrega de lote retido.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000])->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000)
|
||||
->and(app(WarehouseReservations::class)->free($this->f['stock']->id, $this->f['warehouse']->id))->toBe(-4000);
|
||||
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('health.needs_check', true)->where('health.delivery_ready', false));
|
||||
$this->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.needs_check', true)->where('records.data.0.health.delivery_ready', false));
|
||||
});
|
||||
|
||||
test('shortage on a later line rolls back the entire delivery and preserves the authorization', function () {
|
||||
$other = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'ZERO', 'name' => 'Artigo sem saldo', 'unit' => 'cx']);
|
||||
$data = $this->draftData;
|
||||
@@ -134,6 +167,102 @@ test('rejection prevents delivery without changing stock', function () {
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('workdesk state projection matches the authoritative journal through partial deliveries receipts cancellation and rejection', function () {
|
||||
$check = function (string $state) {
|
||||
$derived = WarehouseRequest::query()->whereKey($this->r->id)->selectRaw(app(WarehouseWorkdesk::class)->stateExpression().' as derived_state')->firstOrFail()->getAttribute('derived_state');
|
||||
expect($derived)->toBe($state)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe($state);
|
||||
};
|
||||
$check('draft');
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
$check('submitted');
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
$check('authorized');
|
||||
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
|
||||
$check('partial');
|
||||
$first = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
|
||||
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
|
||||
$check('partial');
|
||||
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '3.000']]]);
|
||||
$check('delivered');
|
||||
$last = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->latest('id')->firstOrFail();
|
||||
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
||||
$check('closed');
|
||||
foreach (['reject' => 'rejected', 'cancel' => 'cancelled'] as $action => $state) {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$data = $this->draftData;
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
if ($action === 'cancel') {
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
}
|
||||
warehouseCommand($this, $this->reviewer, $action);
|
||||
$check($state);
|
||||
}
|
||||
});
|
||||
|
||||
test('workdesk tasks follow existing permissions and segregation without turning consultation into a grant', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
$before = DB::table('warehouse_request_events')->count();
|
||||
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.actions.canReview', true)->where('states.submitted', 1));
|
||||
$this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=review')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
expect(DB::table('warehouse_request_events')->count())->toBe($before);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
|
||||
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.delivery_ready', true));
|
||||
$this->actingAs($this->reviewer)->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('canDeliver', false)->has('people.delivery', 1)->where('people.delivery.0.id', $this->f['receiver']->id));
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->has('people.delivery', 0));
|
||||
});
|
||||
|
||||
test('workdesk retains unconfirmed deliveries after balance cancellation and removes the task only after receipt', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
|
||||
warehouseCommand($this, $this->reviewer, 'cancel');
|
||||
$this->actingAs($this->f['issuer'])->get('/stock/requisicoes?view=receipt&state=cancelled')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.unconfirmed_deliveries', 1));
|
||||
$delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $this->r->id)->where('action', 'deliver')->sole();
|
||||
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
$this->get('/stock/requisicoes?view=receipt')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('cancelled');
|
||||
});
|
||||
|
||||
test('workdesk filtering precedes pagination and finds old requests without adding new movements', function () {
|
||||
$first = $this->r;
|
||||
$this->actingAs($this->f['issuer']);
|
||||
for ($i = 0; $i < 17; $i++) {
|
||||
$data = $this->draftData;
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
|
||||
}
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
$moves = DB::table('stock_movements')->count();
|
||||
$this->actingAs($this->reviewer)->get('/stock/requisicoes?view=review&state=submitted&search=Oficina')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
|
||||
$this->get('/stock/requisicoes?view=all&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 18)->has('records.data', 3));
|
||||
$this->get('/stock/requisicoes?search=RA-'.$first->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $first->id));
|
||||
expect(DB::table('stock_movements')->count())->toBe($moves);
|
||||
});
|
||||
|
||||
test('workdesk counts do not cross company department or warehouse scope', function () {
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['issuer'], ['warehouse_request_prepare']);
|
||||
$this->actingAs($foreign['issuer']);
|
||||
$data = $this->draftData;
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$data['warehouse_id'] = $foreign['warehouse']->id;
|
||||
$data['cost_center_id'] = $foreign['r']->cost_center_id;
|
||||
$data['lines'][0]['stock_item_id'] = $foreign['stock']->id;
|
||||
app(WarehouseRequests::class)->draft($foreign['issuer'], $data);
|
||||
$this->actingAs($this->reviewer)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('views.0.total', 1)->where('records.data.0.id', $this->r->id));
|
||||
$this->getJson('/stock/requisicoes?warehouse_id='.$foreign['warehouse']->id)->assertUnprocessable();
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra unidade do ensaio']);
|
||||
$outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
|
||||
supplyGrant($outsider, ['warehouse_request_review']);
|
||||
$this->actingAs($outsider)->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 0)->where('views.0.total', 0));
|
||||
$this->get('/stock/requisicoes/'.$this->r->id)->assertForbidden();
|
||||
});
|
||||
|
||||
test('changed source and excess quantities are refused on the live document', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
test('synthetic pilot connects partial delivery shortage purchase draft replenishment reservation refill and receipt without an invoice or automatic approval', function () {
|
||||
$f = receiptFixture('300.000');
|
||||
supplyGrant($f['issuer'], ['requester', 'warehouse_request_prepare', 'supply_prepare']);
|
||||
supplyGrant($f['receiver'], ['warehouse', 'warehouse_request_review', 'supply_review']);
|
||||
supplyGrant($f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
$this->actingAs($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '120.000'));
|
||||
app(RequisitionCatalogue::class)->save($f['receiver'], $f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($f['stock']), 'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação do artigo para o ensaio sintético integrado.']);
|
||||
$this->actingAs($f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'purpose' => 'internal', 'origin' => 'local']));
|
||||
$this->actingAs($f['receiver']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
$this->actingAs($f['issuer']);
|
||||
$rule = app(AutomaticPurchases::class)->save($f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Estimativa de dez MZN por unidade apenas para o ensaio descartável.', 'data' => ['policy_key' => $policy->record_key, 'requester_id' => $f['issuer']->id, 'cost_center_id' => $f['r']->cost_center_id, 'unit_price' => '10.00', 'tax_rate' => '0', 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
|
||||
$this->actingAs($f['receiver']);
|
||||
app(AutomaticPurchases::class)->save($f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Conferência independente da parametrização sintética.', 'data' => []]);
|
||||
$this->actingAs($f['issuer']);
|
||||
$request = app(WarehouseRequests::class)->draft($f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $f['warehouse']->id, 'cost_center_id' => $f['r']->cost_center_id, 'destination' => 'Oficina sintética do piloto', 'reason' => 'Necessidade interna de material para manutenção no ensaio.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $f['stock']->id, 'quantity' => '125.000', 'use' => 'internal']]]);
|
||||
$command = function ($actor, string $action, array $extra = []) use ($request) {
|
||||
$this->actingAs($actor);
|
||||
$version = app(WarehouseFulfilment::class)->progress($request)['version'];
|
||||
$this->postJson('/stock/requisicoes/'.$request->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => $version, 'action' => $action, 'reason' => 'Intervenção efectiva no percurso sintético do piloto.', ...$extra])->assertRedirect();
|
||||
};
|
||||
$command($f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
$command($f['receiver'], 'authorize', ['partial_allowed' => true]);
|
||||
app(AutomaticPurchases::class)->evaluate($f['receiver'], $f['stock']->id, $f['warehouse']->id);
|
||||
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
expect($need->data['purchases'])->toHaveCount(1);
|
||||
$purchase = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
||||
expect($purchase->status)->toBe('draft')->and($purchase->items()->sole()->quantity)->toBe('108.000')->and($purchase->steps()->count())->toBe(0);
|
||||
$this->actingAs($f['otherReceiver'])->get('/stock/requisicoes?view=delivery')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.health.needs_reservation', true)->where('records.data.0.health.delivery_ready', true));
|
||||
$command($f['otherReceiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '100.000']]]);
|
||||
$delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'deliver')->sole();
|
||||
$command($f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
||||
expect(app(WarehouseFulfilment::class)->progress($request)['reserved'])->toBe([20000]);
|
||||
$this->actingAs($f['otherReceiver']);
|
||||
// Independent stock can arrive from an already issued order. It does not approve the automatic draft.
|
||||
app(ReceiptWorkflow::class)->confirm($f['otherReceiver'], $f['order'], receiptPayload($f, '108.000'));
|
||||
$command($f['receiver'], 'reserve');
|
||||
expect(app(WarehouseFulfilment::class)->progress($request)['reserved'])->toBe([25000]);
|
||||
$command($f['otherReceiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '25.000']]]);
|
||||
$last = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'deliver')->latest('id')->firstOrFail();
|
||||
$command($f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
||||
$p = app(WarehouseFulfilment::class)->progress($request);
|
||||
expect($p['state'])->toBe('closed')->and($p['reserved'])->toBe([0])->and($p['delivered'])->toBe([125000])
|
||||
->and(app(StockWorkflow::class)->balance($f['stock']->id, $f['warehouse']->id)['quantity'])->toBe(103000)
|
||||
->and($purchase->fresh()->status)->toBe('draft')->and(DB::table('invoices')->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0)
|
||||
->and(Requisition::query()->where('status', 'draft')->count())->toBe(1);
|
||||
$this->get('/stock/requisicoes?view=receipt')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total',0));
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WarehousePilotReadiness;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = suggestionFixture();
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
supplyGrant($this->f['otherReceiver'], ['admin']);
|
||||
});
|
||||
|
||||
function pilotReadinessRule($test): void
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$rule = app(AutomaticPurchases::class)->save($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização sintética do diagnóstico de piloto.', 'data' => ['policy_key' => $test->f['policy']->record_key, 'requester_id' => $test->f['issuer']->id, 'cost_center_id' => $test->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]]);
|
||||
$test->actingAs($test->f['receiver']);
|
||||
app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Activação independente da regra sintética conferida.', 'data' => []]);
|
||||
}
|
||||
|
||||
test('pilot configuration export is private read only and does not include the account register', function () {
|
||||
$counts = [];
|
||||
foreach (['users', 'organization_memberships', 'organization_profiles', 'audit_logs', 'stock_movements', 'supply_records', 'requisitions'] as $table) {
|
||||
$counts[$table] = DB::table($table)->count();
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/piloto.csv')->assertForbidden();
|
||||
$response = $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto.csv')->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$csv = $response->streamedContent();
|
||||
expect($csv)->toContain('Conferido em')->toContain('A preparar')->toContain('SGC autónomo')->not->toContain($this->f['issuer']->name);
|
||||
foreach ($counts as $table => $count) {
|
||||
expect(DB::table($table)->count())->toBe($count);
|
||||
}
|
||||
});
|
||||
|
||||
test('pilot export renders formula-like text as text in spreadsheet readers', function () {
|
||||
$this->mock(WarehousePilotReadiness::class)->shouldReceive('inspect')->once()->andReturn(['checked_at' => now()->toIso8601String(), 'checks' => [['label' => '=1+1', 'ok' => false, 'detail' => "\t@SUM(1)", 'path' => null]]]);
|
||||
$response = $this->actingAs($this->f['otherReceiver'])->get('/stock/piloto.csv')->assertOk();
|
||||
$csv = $response->streamedContent();
|
||||
expect($csv)->toContain("'=1+1")->toContain("'\t@SUM(1)");
|
||||
});
|
||||
|
||||
test('pilot diagnostics are read only and distinguish a viable synthetic warehouse scenario from client delivery and an unobserved scheduler', function () {
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
pilotReadinessRule($this);
|
||||
$counts = [];
|
||||
foreach (['requisitions', 'warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'audit_logs', 'supply_records', 'replenishment_work', 'organization_memberships', 'organization_profiles'] as $table) {
|
||||
$counts[$table] = DB::table($table)->count();
|
||||
}
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('stock/pilot')->where('syntheticEvaluation', true)->where('report.warehouse_scenario_ready', true)->where('report.replenishment_scenario_ready', false)->where('report.client_configuration_complete', false)->where('report.checks.3.ok', true)->where('report.checks.4.ok', false)->has('report.participants.prepare', 1)->has('report.participants.deliver', 1));
|
||||
foreach ($counts as $table => $count) {
|
||||
expect(DB::table($table)->count())->toBe($count);
|
||||
}
|
||||
});
|
||||
|
||||
test('having all task labels among two people does not satisfy three-way independence', function () {
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver', 'warehouse']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.warehouse_scenario_ready', false)->where('report.checks.1.ok', false)->has('report.participants.prepare', 1)->has('report.participants.review', 2)->has('report.participants.deliver', 1));
|
||||
});
|
||||
|
||||
test('rule inspection reuses generation authority and source validity without creating drafts', function () {
|
||||
pilotReadinessRule($this);
|
||||
$before = Requisition::query()->count();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.checks.3.ok', false));
|
||||
expect(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('pilot diagnostics do not admit operational readers or expose accounts from another company', function () {
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/piloto')->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['issuer'], ['admin']);
|
||||
$this->actingAs($foreign['issuer'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->has('report.registered', 3)->where('report.warehouse_scenario_ready', false)->where('report.checks.2.ok', false)->where('report.registered', fn ($people) => collect($people)->pluck('id')->sort()->values()->all() === collect([$foreign['issuer']->id, $foreign['receiver']->id, $foreign['otherReceiver']->id])->sort()->values()->all()));
|
||||
});
|
||||
|
||||
test('stale catalogue and expired automatic configuration are reported instead of being treated as ready', function () {
|
||||
pilotReadinessRule($this);
|
||||
$this->f['stock']->update(['name' => 'Cadastro actualizado depois da publicação']);
|
||||
$this->travel(32)->days();
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/piloto')->assertOk()->assertInertia(fn (Assert $p) => $p->where('report.checks.2.ok', false)->where('report.checks.3.ok', false)->where('report.replenishment_scenario_ready', false));
|
||||
});
|
||||
|
||||
test('optional scheduler telemetry is isolated and a stale or damaged file does not imply a running service', function () {
|
||||
$originalStorage = $this->app->storagePath();
|
||||
$originalEnv = $this->app->environment();
|
||||
$temporary = $originalStorage.'/framework/testing/pilot-status-'.Str::uuid();
|
||||
mkdir($temporary.'/framework', 0755, true);
|
||||
$this->app->useStoragePath($temporary);
|
||||
$this->app->instance('env', 'payable-testing');
|
||||
$path = $temporary.'/framework/replenishment-status.json';
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
try {
|
||||
file_put_contents($path, '{bad-json');
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus'])->toBeNull();
|
||||
file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->subHour()->toIso8601String()], JSON_THROW_ON_ERROR));
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerRecent'])->toBeFalse();
|
||||
file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerRecent'])->toBeTrue();
|
||||
} finally {
|
||||
$this->app->useStoragePath($originalStorage);
|
||||
$this->app->instance('env', $originalEnv);
|
||||
unlink($path);
|
||||
rmdir($temporary.'/framework');
|
||||
rmdir($temporary);
|
||||
}
|
||||
});
|
||||
|
||||
test('a recent successful scheduler does not hide durable work still awaiting recovery', function () {
|
||||
pilotReadinessRule($this);
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->update(['processed_generation' => DB::raw('generation'), 'failures' => 0]);
|
||||
$originalStorage = $this->app->storagePath();
|
||||
$originalEnv = $this->app->environment();
|
||||
$temporary = $originalStorage.'/framework/testing/pilot-recovery-'.Str::uuid();
|
||||
mkdir($temporary.'/framework', 0755, true);
|
||||
$this->app->useStoragePath($temporary);
|
||||
$this->app->instance('env', 'payable-testing');
|
||||
$path = $temporary.'/framework/replenishment-status.json';
|
||||
try {
|
||||
file_put_contents($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
|
||||
expect(app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver'])['replenishment_scenario_ready'])->toBeTrue();
|
||||
DB::table('replenishment_work')->where('tenant_id', $this->f['tenant']->id)->update(['failures' => 1, 'due_at' => now()->addHour()]);
|
||||
$report = app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver']);
|
||||
expect($report['runtime']['schedulerRecent'])->toBeTrue()->and($report['replenishment_scenario_ready'])->toBeFalse()->and($report['checks'][6]['ok'])->toBeFalse();
|
||||
} finally {
|
||||
$this->app->useStoragePath($originalStorage);
|
||||
$this->app->instance('env', $originalEnv);
|
||||
unlink($path);
|
||||
rmdir($temporary.'/framework');
|
||||
rmdir($temporary);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\{StockMovement,StockOperation,SupplyRecord,User,WarehouseRequestEvent};
|
||||
use App\Services\{ReceiptWorkflow,WarehouseFulfilment,WarehouseRequests};
|
||||
use Illuminate\Support\Facades\{Artisan,DB};
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
final class WarehouseReturnIntegrationTest extends TestCase
|
||||
{
|
||||
private array $f;private User $reviewer;private int $requestId;private int $delivery;private array $payload;
|
||||
protected function setUp():void
|
||||
{
|
||||
parent::setUp();$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertMatchesRegularExpression('/^compras_receipts_test_[a-f0-9]{12}$/D',$i->db);$this->assertSame('stock_receipt_qa_'.substr($i->db,-12),$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
|
||||
if(!DB::getSchemaBuilder()->hasTable('users'))Artisan::call('migrate',['--force'=>true]);config(['stock_returns.enabled'=>true]);
|
||||
$this->f=\receiptFixture('10.000');$this->reviewer=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'department_id'=>$this->f['issuer']->department_id]);
|
||||
\supplyGrant($this->f['issuer'],['warehouse_request_prepare','warehouse_return_prepare','warehouse_return_read','warehouse_return_confirm','warehouse']);
|
||||
\supplyGrant($this->reviewer,['warehouse_request_review']);\supplyGrant($this->f['receiver'],['warehouse','warehouse_request_deliver','warehouse_return_read','warehouse_return_confirm']);\supplyGrant($this->f['otherReceiver'],['warehouse','warehouse_return_read']);
|
||||
$this->actingAs($this->f['receiver']);app(ReceiptWorkflow::class)->confirm($this->f['receiver'],$this->f['order'],\receiptPayload($this->f,'10.000'));
|
||||
$this->actingAs($this->f['issuer']);$r=app(WarehouseRequests::class)->draft($this->f['issuer'],['operation_key'=>(string)Str::uuid(),'warehouse_id'=>$this->f['warehouse']->id,'cost_center_id'=>$this->f['r']->cost_center_id,'destination'=>'Synthetic internal workshop','reason'=>'Synthetic authorised internal material use.','priority'=>'normal','lines'=>[['stock_item_id'=>$this->f['stock']->id,'quantity'=>'5.000','use'=>'internal','equipment_id'=>null]]]);$this->requestId=$r->id;
|
||||
$this->command($this->f['issuer'],'submit',['partial_allowed'=>false]);$this->command($this->reviewer,'authorize',['partial_allowed'=>false]);$this->command($this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'5.000']]]);
|
||||
$this->delivery=(int)WarehouseRequestEvent::query()->where('warehouse_request_id',$r->id)->where('action','deliver')->value('id');$this->command($this->f['issuer'],'receive',['delivery_event_id'=>$this->delivery]);
|
||||
$this->payload=['operation_key'=>(string)Str::uuid(),'delivery_event_id'=>$this->delivery,'line_index'=>0,'quantity'=>'3.000','declared_unused'=>true,'evidence'=>'Synthetic unused quantity and physical custody evidence.','reason'=>'Synthetic unused material returned to its original warehouse.'];$this->actingAs($this->f['issuer']);
|
||||
}
|
||||
private function command(User $actor,string $action,array $extra):void
|
||||
{
|
||||
$this->actingAs($actor);$r=\App\Models\WarehouseRequest::query()->findOrFail($this->requestId);$d=['operation_key'=>(string)Str::uuid(),'version'=>app(WarehouseFulfilment::class)->progress($r)['version'],'action'=>$action,'reason'=>'Synthetic explicit request intervention.']+$extra;
|
||||
$this->postJson('/stock/requisicoes/'.$r->id.'/eventos',$d)->assertRedirect();
|
||||
}
|
||||
private function prepare(?array $payload=null){return $this->postJson('/api/stock-returns/prepare',$payload??$this->payload);}
|
||||
private function confirm(int $id,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/confirm',['version'=>$version,'received_in_warehouse'=>true,'reason'=>'Synthetic independent physical warehouse receipt.']);}
|
||||
private function closeProposal(int $id,string $action,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/'.$action,['version'=>$version,'reason'=>'Synthetic reason for closing without physical movement.']);}
|
||||
private function balance():int{return (int)StockMovement::query()->sum('quantity_milli');}
|
||||
public function test_native_receipt_issue_acknowledgement_and_return_same_book_once():void
|
||||
{
|
||||
$this->assertSame(5000,$this->balance());$p=$this->prepare()->assertOk()->assertJsonPath('impact.physical_before','5000')->assertJsonPath('impact.physical_after','8000')->json('id');$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p)->assertForbidden();
|
||||
$prepared=SupplyRecord::query()->findOrFail($p);$this->assertSame($this->f['issuer']->id,$prepared->snapshot['source']['acknowledgement']['actor_id']);
|
||||
$this->actingAs($this->f['receiver']);$this->postJson('/api/stock-returns/'.$p.'/confirm',['version'=>1,'reason'=>'Synthetic receipt without acknowledgement'])->assertStatus(422);$m=$this->confirm($p)->assertOk()->json('movement_id');$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$m)->assertJsonPath('replayed',true);
|
||||
$this->assertSame(8000,$this->balance());$this->assertSame(3,StockMovement::query()->count());$this->assertSame(2,StockOperation::query()->count());$this->assertSame(2,SupplyRecord::query()->where('kind','warehouse_return')->count());
|
||||
$history=$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->component('stock/operations')->where('canViewReturns',true)->where('operations.data.0.kind','workshop_return')->where('operations.data.0.snapshot.stock_item.id',$this->f['stock']->id)->where('operations.data.0.snapshot.warehouse.id',$this->f['warehouse']->id)->where('operations.data.0.snapshot.actor.id',$this->f['receiver']->id)->where('operations.data.0.snapshot.responsible.id',$this->f['receiver']->id));
|
||||
$historyRoot=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($historyRoot);file_put_contents($historyRoot.'/native-stock-history-page.json',json_encode($history->viewData('page'),JSON_THROW_ON_ERROR));
|
||||
$this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1)->assertJsonPath('rows.0.version',2);$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);
|
||||
$this->assertTrue(DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.confirmed')->exists());
|
||||
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);file_put_contents($root.'/return-results.json',json_encode(['kind'=>'native_receipt_internal_issue_and_return','tenant'=>$this->f['tenant']->id,'warehouse'=>$this->f['warehouse']->id,'item'=>$this->f['stock']->id,'return_movement'=>$m,'request_id'=>$this->requestId,'physical_milli'=>'8000','native_movements'=>3,'shared_return_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-return/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
}
|
||||
public function test_stale_capacity_idempotency_versions_rights_and_context_refuse_effect():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$changed=$this->payload;$changed['quantity']='2.000';$this->prepare($changed)->assertConflict();$second=$this->payload;$second['operation_key']=(string)Str::uuid();$other=$this->prepare($second)->assertOk()->json('id');
|
||||
$this->actingAs($this->f['otherReceiver']);$this->confirm($p)->assertForbidden();$this->actingAs($this->f['receiver']);$this->confirm($p,2)->assertStatus(422);$this->confirm($p)->assertOk();$this->confirm($other)->assertConflict();$this->assertSame(8000,$this->balance());
|
||||
$this->actingAs($this->f['issuer']);$second['quantity']='2.001';$second['operation_key']=(string)Str::uuid();$this->prepare($second)->assertConflict();$second['quantity']='2.000';$second['operation_key']=(string)Str::uuid();$p=$this->prepare($second)->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(10000,$this->balance());
|
||||
config(['stock_returns.enabled'=>false]);$this->getJson('/api/stock-returns')->assertStatus(503);$this->assertSame(10000,$this->balance());
|
||||
}
|
||||
public function test_decision_failure_rolls_back_physical_movement_and_retry_recovers():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*supply_records/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic native return decision failure');}});
|
||||
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Missing fault');}catch(\RuntimeException $e){$this->assertSame('Synthetic native return decision failure',$e->getMessage());}$this->withExceptionHandling();
|
||||
$this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_undeclared_fraction_and_foreign_delivery_are_refused():void
|
||||
{
|
||||
$d=$this->payload;$d['declared_unused']=false;$this->prepare($d)->assertStatus(422);$d=$this->payload;$d['quantity']='0.0001';$this->prepare($d)->assertStatus(422);
|
||||
$foreign=User::factory()->create(['tenant_id'=>\App\Models\Tenant::query()->create(['name'=>'Synthetic other native company'])->id]);\supplyGrant($foreign,['warehouse_return_prepare','warehouse_return_confirm','warehouse_return_read','warehouse']);$this->actingAs($foreign);$this->prepare()->assertNotFound();$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',0);
|
||||
}
|
||||
public function test_source_options_and_page_reuse_request_and_acknowledged_line():void
|
||||
{
|
||||
$this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('options.0.delivery_event_id',$this->delivery)->assertJsonPath('options.0.item.code',$this->f['stock']->code)->assertJsonPath('can_prepare',true);
|
||||
$this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertOk()->assertHeader('Cache-Control','max-age=0, no-store, private')->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',$this->requestId)->where('canPrepare',true));
|
||||
$this->getJson('/api/stock-returns?state=wrong')->assertStatus(422);$this->getJson('/api/stock-returns?page=0')->assertStatus(422);$this->getJson('/api/stock-returns?tenant_id=999')->assertStatus(422);
|
||||
$this->actingAs($this->f['receiver']);$this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('can_prepare',false);$this->assertSame(5000,$this->balance());
|
||||
$this->actingAs($this->f['otherReceiver']);$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->where('canViewReturns',true));$this->get('/stock/devolucoes-internas')->assertOk()->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',null)->where('canConfirm',false));$this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertForbidden();
|
||||
}
|
||||
public function test_source_issue_with_return_cannot_be_reversed_as_a_whole():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$operation=(int)StockOperation::query()->where('kind','issue')->value('id');
|
||||
try{app(\App\Services\InventoryReversals::class)->basis(['source_kind'=>'operation','source_operation_id'=>$operation]);$this->fail('Original issue with return must not be reversed');}catch(\Illuminate\Validation\ValidationException $e){$this->assertStringContainsString('devolução física',implode(' ',array_merge(...array_values($e->errors()))));}
|
||||
$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_concurrent_native_confirmation_waits_then_replays_one_movement():void
|
||||
{
|
||||
$this->nativeRace();
|
||||
}
|
||||
private function nativeRace(?int $given=null,?int $workerProposal=null,int $workerStatus=200,bool $parentWithdraw=false,?User $workerActor=null,string $workerAction='confirm'):void
|
||||
{
|
||||
$p=$given??$this->prepare()->assertOk()->json('id');$this->actingAs($this->f[$parentWithdraw?'issuer':'receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-race-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='native_return_'.bin2hex(random_bytes(6));$process=null;
|
||||
file_put_contents($input,json_encode(['actor'=>($workerActor??$this->f['receiver'])->id,'proposal'=>$workerProposal??$p,'application'=>$application,'action'=>$workerAction],JSON_THROW_ON_ERROR));DB::beginTransaction();
|
||||
try{$winner=$parentWithdraw?$this->closeProposal($p,'withdraw')->assertOk()->json('id'):$this->confirm($p)->assertOk()->json('movement_id');$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
|
||||
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
|
||||
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$result=json_decode(file_get_contents($input.'.result'),true);$this->assertSame($workerStatus,$result['status']);if($workerStatus===200){$this->assertTrue($result['result']['replayed']);$this->assertSame($winner,$result['result']['movement_id']);}
|
||||
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
$this->assertSame($parentWithdraw?5000:8000,$this->balance());$this->assertSame($parentWithdraw?2:3,StockMovement::query()->count());$this->assertSame(1,SupplyRecord::query()->where('kind','warehouse_return')->where('state',$parentWithdraw?'withdrawn':'confirmed')->count());
|
||||
}
|
||||
public function test_competing_native_returns_cannot_exceed_the_original_delivery():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$d=$this->payload;$d['operation_key']=(string)Str::uuid();$other=$this->prepare($d)->assertOk()->json('id');$this->nativeRace($p,$other,409);
|
||||
}
|
||||
public function test_expired_and_maintenance_profiles_are_revalidated_at_confirmation():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>true]);$this->confirm($p)->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>false,'expires_at'=>now()->subMinute()]);$this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['expires_at'=>null]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_configured_context_and_permissions_cannot_be_inherited_from_people_profile():void
|
||||
{
|
||||
$tenant=$this->f['tenant']->id;$this->f['tenant']->update(['rules_provisional'=>false,'work_context_policy'=>['revision'=>1,'enabled'=>['procurement.internal','people.internal']]]);config(['work_context.entitlements.'.$tenant=>['procurement.internal','people.internal']]);DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);
|
||||
$this->prepare()->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');
|
||||
$profiles=DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->pluck('profile_id');DB::table('organization_profiles')->whereIn('id',$profiles)->update(['permissions'=>json_encode(['warehouse','warehouse_request_deliver','warehouse_return_read'])]);\supplyGrant($this->f['receiver'],['warehouse_return_confirm']);$other=DB::table('organization_profiles')->where('tenant_id',$tenant)->whereNotIn('id',$profiles)->orderByDesc('id')->value('id');DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['people.internal'])]);
|
||||
$this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_return_signals_existing_replenishment_only_once_and_inside_transaction():void
|
||||
{
|
||||
$spy=new class extends \App\Services\AutomaticPurchases {public array $calls=[];public function signal(User $actor,int $item,int $warehouse):void {$this->calls[]=['actor'=>$actor->id,'item'=>$item,'warehouse'=>$warehouse,'transaction_level'=>DB::transactionLevel()];}};
|
||||
$this->app->instance(\App\Services\AutomaticPurchases::class,$spy);$p=$this->prepare()->assertOk()->json('id');$this->assertCount(0,$spy->calls);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);
|
||||
$this->assertCount(1,$spy->calls);$this->assertSame($this->f['stock']->id,$spy->calls[0]['item']);$this->assertSame($this->f['warehouse']->id,$spy->calls[0]['warehouse']);$this->assertGreaterThan(0,$spy->calls[0]['transaction_level']);$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_catalogue_rename_requires_new_review_preserving_original_and_unit_change_refuses():void
|
||||
{
|
||||
$name=$this->f['stock']->name;$p=$this->prepare()->assertOk()->json('id');$this->f['stock']->update(['name'=>'Synthetic renamed article']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->assertSame(5000,$this->balance());
|
||||
$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->assertJsonPath('impact.item.name','Synthetic renamed article')->json('id');$this->assertSame($name,SupplyRecord::query()->findOrFail($p)->snapshot['source']['original_item']['name']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
$this->f['stock']->update(['unit'=>'KG']);$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$this->payload['quantity']='1.000';$this->prepare()->assertConflict();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_unavailable_private_package_returns_503_and_rolls_back_before_recovery():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$manifest=realpath(base_path('packages/inventory-return/releases/0.1.0/manifest.json'));$this->assertStringStartsWith($root.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR,$manifest);$original=file_get_contents($manifest);$this->assertNotFalse($original);
|
||||
try{file_put_contents($manifest,'{"synthetic_private_fault":true}');$this->confirm($p)->assertStatus(503);}finally{file_put_contents($manifest,$original);}
|
||||
$this->assertSame(5000,$this->balance());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(2,StockMovement::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_http_busy_response_leaves_proposal_unconfirmed_then_manual_retry_succeeds():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-busy-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$appName='native_return_busy_'.bin2hex(random_bytes(6));$process=null;file_put_contents($input,json_encode(['actor'=>$this->f['receiver']->id,'proposal'=>$p,'application'=>$appName,'http'=>true],JSON_THROW_ON_ERROR));DB::beginTransaction();
|
||||
try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->first();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
|
||||
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$appName]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));
|
||||
$deadline=microtime(true)+12;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(423,$r['status']);$this->assertSame('warehouse_return_busy',$r['result']['code']);$this->assertArrayNotHasKey('exception',$r['result']);$this->assertSame(5000,$this->balance());DB::commit();
|
||||
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
$this->assertSame(0,SupplyRecord::query()->where('kind','warehouse_return')->where('state','confirmed')->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
||||
}
|
||||
public function test_withdrawal_and_independent_rejection_close_pending_without_stock_effect():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->closeProposal($p,'reject')->assertForbidden();\supplyGrant($this->f['receiver'],['warehouse_return_prepare']);$this->actingAs($this->f['receiver']);$this->closeProposal($p,'withdraw')->assertForbidden();$this->actingAs($this->f['issuer']);$this->closeProposal($p,'withdraw',2)->assertConflict();$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('state','withdrawn');$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('state','withdrawn');
|
||||
$this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);$this->getJson('/api/stock-returns?state=withdrawn')->assertOk()->assertJsonPath('pagination.total',1);
|
||||
$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('state','rejected');$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('replayed',true);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=rejected')->assertOk()->assertJsonPath('pagination.total',1);
|
||||
$this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(4,SupplyRecord::query()->where('kind','warehouse_return')->count());$this->assertSame(1,DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.withdrawn')->count());
|
||||
}
|
||||
public function test_withdrawal_wins_before_confirmation_without_a_movement():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,true);
|
||||
}
|
||||
public function test_confirmation_wins_before_withdrawal_without_duplicate_terminal_state():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,false,$this->f['issuer'],'withdraw');
|
||||
}
|
||||
public function test_return_changes_physical_history_without_silently_changing_gross_issue_forecast():void
|
||||
{
|
||||
$history=app(\App\Services\SupplyHistory::class);$date=now()->toDateString();
|
||||
$before=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
|
||||
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();
|
||||
$after=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
|
||||
$this->assertSame(5000,$before['total_milli']);$this->assertSame(5000,$after['total_milli']);
|
||||
$this->assertSame($before['daily_mean_milli'],$after['daily_mean_milli']);
|
||||
$this->assertSame(5000,$before['series'][0]['balance_milli']);$this->assertSame(8000,$after['series'][0]['balance_milli']);
|
||||
$this->assertSame(1,$after['movement_types']['workshop_return_in']);$this->assertNotSame($before['source_hash'],$after['source_hash']);
|
||||
$this->assertSame($before['convention'],$after['convention']);$this->assertSame(3,$after['movement_count']);
|
||||
$this->assertSame(8000,$this->balance());
|
||||
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-forecast-boundary.json',json_encode(['gross_issue_milli'=>5000,'returned_milli'=>3000,'physical_before_milli'=>5000,'physical_after_milli'=>8000,'historical_mean_unchanged'=>true,'source_hash_changed'=>true,'planning_convention_unchanged'=>true,'net_consumption_forecast_implemented'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
}
|
||||
public function test_history_pages_and_batch_preparer_lookup_cover_the_full_pending_universe():void
|
||||
{
|
||||
$p=$this->prepare()->assertOk()->json('id');$row=SupplyRecord::query()->findOrFail($p)->getAttributes();unset($row['id']);for($i=0;$i<50;$i++){$copy=$row;$key=(string)Str::uuid();$copy['record_key']=$key;$copy['operation_key']=$key;$data=json_decode($copy['data'],true,512,JSON_THROW_ON_ERROR);$data['operation_key']=$key;$copy['data']=json_encode($data,JSON_THROW_ON_ERROR);$copy['input_hash']=hash('sha256',$copy['data']);DB::table('supply_records')->insert($copy);}
|
||||
$queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture&&str_starts_with(strtolower($q->sql),'select')&&str_contains($q->sql,'"supply_records"'))$queries[]=$q->sql;});$first=$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',51)->assertJsonPath('pagination.last_page',3)->assertJsonCount(25,'rows')->json('rows');$capture=false;$this->assertLessThanOrEqual(4,count($queries));
|
||||
$second=$this->getJson('/api/stock-returns?page=2')->assertOk()->assertJsonCount(25,'rows')->json('rows');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));$this->assertGreaterThan($second[0]['id'],$first[24]['id']);$this->getJson('/api/stock-returns?page=3')->assertOk()->assertJsonCount(1,'rows');
|
||||
$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',50);$this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1);$this->assertSame(8000,$this->balance());
|
||||
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-history-metrics.json',json_encode(['fixture_records'=>51,'page_size'=>25,'supply_record_selects_for_page'=>count($queries),'pages_disjoint'=>true,'state_filters_cover_latest_versions'=>true,'not_a_load_capacity_benchmark'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PeopleAccess;
|
||||
use App\Services\PeopleHiring;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
$this->head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id]);
|
||||
supplyGrant($this->f['issuer'], ['requester', 'warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
supplyGrant($this->head, ['department_head']);
|
||||
supplyGrant($this->head, ['warehouse_request_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['admin']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']),
|
||||
'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação sintética para reutilizar o cadastro nas tarefas.']);
|
||||
});
|
||||
|
||||
function taskInboxRequest($test, string $quantity = '4.000'): void
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$test->r = app(WarehouseRequests::class)->draft($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'cost_center_id' => $test->f['r']->cost_center_id, 'priority' => 'normal', 'destination' => 'Oficina sintética', 'reason' => 'Material para manutenção no ensaio de tarefas.',
|
||||
'lines' => [['stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'use' => 'internal']]]);
|
||||
taskInboxCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
}
|
||||
|
||||
function taskInboxCommand($test, User $actor, string $action, array $extra = [])
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return app(WarehouseFulfilment::class)->command($actor, $test->r->id, ['operation_key' => (string) Str::uuid(),
|
||||
'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Intervenção efectiva no ensaio das tarefas.', ...$extra]);
|
||||
}
|
||||
|
||||
function taskInboxPurchase($test)
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$entry = DB::table('requisition_catalogue')->where('stock_item_id', $test->f['stock']->id)->sole();
|
||||
|
||||
return app(RequisitionWorkflow::class)->save($test->f['issuer'], ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $test->f['r']->cost_center_id,
|
||||
'priority' => 'normal', 'needed_by' => today()->addWeek()->toDateString(), 'justification' => 'Compra sintética para validar o encaminhamento de tarefas.',
|
||||
'items' => [['catalogue_entry_id' => $entry->id, 'catalogue_version' => $entry->version, 'quantity' => '2.000', 'unit_price' => '10.00', 'tax_rate' => '0']]]);
|
||||
}
|
||||
|
||||
test('task inbox connects purchasing draft and real human approval without granting administrative execution', function () {
|
||||
$purchase = taskInboxPurchase($this);
|
||||
$this->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$purchase->id));
|
||||
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
|
||||
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'approve'));
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->head);
|
||||
app(RequisitionWorkflow::class)->decide($this->head, $purchase->fresh(), 'approved', 'Conferência humana no ensaio sintético.');
|
||||
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('warehouse review delivery and individual receipt tasks follow the existing commands including cancelled balances', function () {
|
||||
taskInboxRequest($this);
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'review'));
|
||||
taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
|
||||
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'delivery')->where('tasks.data.0.blocked', false));
|
||||
$first = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
|
||||
$second = taskInboxCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.000']]]);
|
||||
taskInboxCommand($this, $this->head, 'cancel');
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)
|
||||
->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$first->id)
|
||||
->where('tasks.data.1.href', '/stock/requisicoes/'.$this->r->id.'#warehouse-command-receive-'.$second->id));
|
||||
taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $first->id]);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
taskInboxCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $second->id]);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('reservation deficits and blocked delivery remain visible to the appropriate people', function () {
|
||||
taskInboxRequest($this, '25.000');
|
||||
taskInboxCommand($this, $this->head, 'authorize', ['partial_allowed' => true]);
|
||||
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.task', 'reserve'));
|
||||
$this->f['stock']->update(['name' => 'Cadastro alterado depois da autorização']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.data.0.blocked', true)->where('tasks.data.0.href', '/stock/requisicoes/'.$this->r->id));
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('task filtering pagination and oldest records use the whole authorised scope', function () {
|
||||
$first = null;
|
||||
foreach (range(1, 30) as $i) {
|
||||
$purchase = taskInboxPurchase($this);
|
||||
$first ??= $purchase;
|
||||
}
|
||||
taskInboxRequest($this);
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 25)->where('tasks.data.0.reference', $first->number));
|
||||
$this->get('/requisicoes/tarefas?page=2')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 30)->has('tasks.data', 5));
|
||||
$this->get('/requisicoes/tarefas?search='.urlencode($first->number))->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 30));
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('counts.purchases', 0)->where('counts.warehouse', 1));
|
||||
});
|
||||
|
||||
test('consulting tasks does not record decisions read receipts reservations or stock changes', function () {
|
||||
taskInboxRequest($this);
|
||||
$this->actingAs($this->head);
|
||||
$tables = ['warehouse_request_events', 'warehouse_reservations', 'stock_movements', 'requisitions', 'approval_steps', 'audit_logs', 'supply_records'];
|
||||
$before = [];
|
||||
foreach ($tables as $table) {
|
||||
$before[$table] = DB::table($table)->get()->toArray();
|
||||
}
|
||||
$this->get('/requisicoes/tarefas')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private');
|
||||
foreach ($tables as $table) {
|
||||
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
|
||||
}
|
||||
$profile = DB::table('organization_profiles')->whereJsonContains('permissions', 'warehouse_request_review')->value('id');
|
||||
DB::table('organization_memberships')->where('user_id', $this->head->id)->where('profile_id', $profile)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('people tasks reuse the assigned team and stay separate from purchasing and warehouse', function () {
|
||||
$owner = $this->f['otherReceiver'];
|
||||
$reviewer = User::factory()->create(['tenant_id' => $owner->tenant_id, 'department_id' => $owner->department_id]);
|
||||
supplyGrant($reviewer, ['people_service_review']);
|
||||
$this->actingAs($owner);
|
||||
$process = app(PeopleHiring::class)->execute($owner, null, 'create', ['operation_key' => (string) Str::uuid(), 'kind' => 'service', 'title' => 'Contratação sintética do ensaio',
|
||||
'purpose' => 'Necessidade documentada para testar a equipa responsável.', 'closes_on' => today()->addWeek()->toDateString(), 'minimum_reviews' => 1, 'criteria' => [['label' => 'Competências', 'weight' => 100]]]);
|
||||
app(PeopleAccess::class)->insert($process, $reviewer->id, ['view', 'review'], 'Equipa atribuída no ensaio descartável.');
|
||||
$this->actingAs($reviewer)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('area', 'people')->where('tasks.total', 1)->where('tasks.data.0.module', 'people')->where('tasks.data.0.task', 'open'));
|
||||
$this->getJson('/pessoas/tarefas?module=warehouse')->assertUnprocessable();
|
||||
$this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($reviewer);
|
||||
app(PeopleHiring::class)->execute($reviewer, $process->id, 'open', ['version' => $process->version, 'no_conflict' => true, 'reason' => 'Conferência independente da abertura no ensaio.']);
|
||||
$this->actingAs($owner)->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'apply'));
|
||||
DB::table('people_process_access')->where('people_process_id', $process->id)->where('user_id', $owner->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/pessoas/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('tasks do not cross company or the currently selected work context', function () {
|
||||
taskInboxRequest($this);
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['issuer'], ['admin']);
|
||||
$this->actingAs($foreign['issuer'])->get('/requisicoes/tarefas')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$actor = $this->f['otherReceiver'];
|
||||
$this->f['tenant']->update(['work_context_policy' => ['revision' => 1, 'enabled' => ['people.internal', 'procurement.internal']]]);
|
||||
config(['work_context.entitlements.'.$actor->tenant_id => ['people.internal', 'procurement.internal']]);
|
||||
DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['people.internal', 'procurement.internal'])]);
|
||||
$this->actingAs($actor)->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas')->assertForbidden();
|
||||
$this->get('/pessoas/tarefas')->assertOk();
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
||||
$this->get('/pessoas/tarefas')->assertForbidden();
|
||||
});
|
||||
|
||||
test('temporary maintenance does not receive operational decisions even with the corresponding profile', function () {
|
||||
$purchase = taskInboxPurchase($this);
|
||||
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $purchase);
|
||||
DB::table('organization_memberships')->where('user_id', $this->head->id)->update(['maintenance' => true]);
|
||||
$this->actingAs($this->head)->get('/requisicoes/tarefas')->assertOk()->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect($purchase->fresh()->status)->toBe('pending');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Integration\WarehouseFeed;
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
|
||||
require_once __DIR__.'/../../packages/integration/bootstrap.php';
|
||||
|
||||
/** Only disposable test stores; never installs the receiver in an application. */
|
||||
function inboxConnection(): PDO
|
||||
{
|
||||
if (getenv('INBOX_TEST_PG') === '1') {
|
||||
$name = getenv('DB_DATABASE');
|
||||
if (getenv('DB_HOST') !== '127.0.0.1' || ! is_string($name) || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $name)) {
|
||||
throw new LogicException('Inbox PG exige uma base descartável identificada.');
|
||||
}
|
||||
$db = new PDO('pgsql:host=127.0.0.1;port='.getenv('DB_PORT').';dbname='.$name, getenv('DB_USERNAME'), getenv('DB_PASSWORD'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
if ($db->query('SELECT current_database()')->fetchColumn() !== $name) {
|
||||
throw new LogicException('Destino de teste divergente.');
|
||||
}
|
||||
} else {
|
||||
$db = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
}
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
function inboxDatabase(): PDO
|
||||
{
|
||||
$db = inboxConnection();
|
||||
if (getenv('INBOX_TEST_PG') === '1') {
|
||||
$db->exec('DROP TABLE IF EXISTS sgc_exchange_facts');
|
||||
$db->exec('DROP TABLE IF EXISTS sgc_exchange_streams');
|
||||
}
|
||||
foreach (WarehouseInbox::schema() as $sql) {
|
||||
$db->exec($sql);
|
||||
}
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
function inboxEvent(int $id = 1, string $action = 'deliver', ?string $delivery = null): array
|
||||
{
|
||||
$event = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => 'sgc:7:warehouse-event:'.$id,
|
||||
'tenant_id' => 7, 'event_id' => $id, 'event_version' => $id + 2, 'action' => $action,
|
||||
'occurred_at' => '2026-11-02T10:00:00+00:00', 'actor' => ['id' => 3, 'name' => 'Operador sintético'],
|
||||
'request' => ['id' => 19, 'reference' => 'RA-19', 'document_type' => 'internal_warehouse_request',
|
||||
'work_context_key' => 'procurement.internal', 'requester' => ['id' => 5, 'name' => 'Requisitante sintético', 'department_id' => 2],
|
||||
'warehouse' => ['id' => 1, 'code' => 'ARM', 'name' => 'Armazém sintético'], 'cost_center' => ['id' => 2, 'code' => 'CC', 'name' => 'Centro sintético'], 'destination' => 'Oficina sintética'],
|
||||
'lines' => $action === 'deliver' ? [['request_line_index' => 0, 'item' => ['id' => 4, 'code' => 'ART', 'name' => 'Artigo sintético', 'unit' => 'un'],
|
||||
'quantity_milli' => 1500, 'use' => 'internal', 'destination' => 'Oficina sintética', 'equipment' => null, 'stock_operation_id' => $id + 10]] : [],
|
||||
'delivery_event_key' => $delivery];
|
||||
|
||||
return inboxSign($event);
|
||||
}
|
||||
|
||||
function inboxSign(array $event): array
|
||||
{
|
||||
unset($event['payload_sha256']);
|
||||
|
||||
return [...$event, 'payload_sha256' => hash('sha256', WarehouseFeed::json($event))];
|
||||
}
|
||||
|
||||
function inboxPage(array $events, string $cursor = 'synthetic-opaque-cursor', bool $more = false): array
|
||||
{
|
||||
return ['schema' => 'sgc.warehouse.fulfilment-feed.v1', 'items' => $events, 'next_cursor' => $cursor, 'has_more' => $more];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$file=realpath($argv[1]??'');
|
||||
if(!$root||!$file||dirname($file)!==$root)throw new RuntimeException('Unexpected private worker');
|
||||
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS name,current_user AS role,current_setting('cluster_name') AS cluster");
|
||||
if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong receipt worker database');
|
||||
$input=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
|
||||
$db->select("SELECT set_config('application_name',?,false)",[$input['application']]);$db->statement("SET lock_timeout='8s'");$db->statement("SET statement_timeout='12s'");
|
||||
$actor=App\Models\User::findOrFail($input['actor']);auth()->guard()->setUser($actor);
|
||||
file_put_contents($file.'.ready','ready');
|
||||
$start=hrtime(true);
|
||||
try{$receipt=app(App\Services\ReceiptWorkflow::class)->confirm($actor,App\Models\PurchaseOrder::findOrFail($input['order']),$input['payload']);
|
||||
$result=['status'=>200,'receipt_id'=>$receipt->id];
|
||||
}catch(Illuminate\Validation\ValidationException){$result=['status'=>422];}
|
||||
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$result=['status'=>$e->getStatusCode()];}
|
||||
$result['elapsed_ms']=round((hrtime(true)-$start)/1e6,3);file_put_contents($file.'.result',json_encode($result,JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/** Runs only inside the existing disposable-database proof, never on an application. */
|
||||
function inboxPgRaces(array $environment, array $config, string $database): array
|
||||
{
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $database) || ($environment['INBOX_TEST_PG'] ?? null) !== '1') {
|
||||
throw new LogicException('Disputas de recepção exigem destino descartável.');
|
||||
}
|
||||
config(['database.connections.inbox_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
$reader = DB::connection('inbox_proof_read');
|
||||
$worker = fn (string $mode, int $id = 1) => new Process([PHP_BINARY, 'tests/Concurrency/inbox-worker.php', $mode, (string) $id], dirname(__DIR__, 2), $environment, timeout: 20);
|
||||
$races = [];
|
||||
try {
|
||||
foreach (['same_page', 'different_pages'] as $scenario) {
|
||||
$worker('prepare')->mustRun();
|
||||
$first = $worker('hold');
|
||||
$first->start();
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && ! str_contains($first->getOutput(), 'locked') && microtime(true) < $deadline) {
|
||||
usleep(20000);
|
||||
}
|
||||
if (! str_contains($first->getOutput(), 'locked')) {
|
||||
throw new RuntimeException('Primeiro receptor sem bloqueio demonstrado.');
|
||||
}
|
||||
$second = $worker('run', $scenario === 'same_page' ? 1 : 2);
|
||||
$second->start();
|
||||
$blocked = false;
|
||||
$deadline = microtime(true) + 5;
|
||||
while ($first->isRunning() && microtime(true) < $deadline) {
|
||||
$blocked = (bool) $reader->selectOne("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=? AND wait_event_type='Lock') AS blocked", [$database])->blocked;
|
||||
if ($blocked) {
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
}
|
||||
$first->wait();
|
||||
$second->wait();
|
||||
if (! $first->isSuccessful() || ! $second->isSuccessful() || ! $blocked) {
|
||||
throw new RuntimeException('Disputa sem execução/bloqueio confirmados.');
|
||||
}
|
||||
$a = json_decode(str_replace('locked', '', $first->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
||||
$b = json_decode($second->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
$accepted = ($a['result'] === 'accepted' ? 1 : 0) + ($b['result'] === 'accepted' ? 1 : 0);
|
||||
if ($accepted !== ($scenario === 'same_page' ? 2 : 1)
|
||||
|| ($scenario === 'same_page' && (($a['inserted'] + $b['inserted']) !== 1 || ($a['replayed'] === $b['replayed'])))
|
||||
|| (int) $reader->table('sgc_exchange_facts')->count() !== 1
|
||||
|| (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
|
||||
throw new RuntimeException('Disputa duplicou factos ou fez avançar o cursor indevidamente.');
|
||||
}
|
||||
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'facts' => 1, 'checkpoint_version' => 2, 'result' => 'passed'];
|
||||
}
|
||||
$worker('prepare')->mustRun();
|
||||
$interrupted = $worker('exit-after-commit');
|
||||
$interrupted->run();
|
||||
if ($interrupted->getExitCode() !== 42) {
|
||||
throw new RuntimeException('Interrupção após commit não demonstrada.');
|
||||
}
|
||||
$retry = $worker('run');
|
||||
$retry->mustRun();
|
||||
$result = json_decode($retry->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (($result['replayed'] ?? false) !== true || $result['inserted'] !== 0 || (int) $reader->table('sgc_exchange_facts')->count() !== 1
|
||||
|| (int) $reader->table('sgc_exchange_streams')->value('version') !== 2) {
|
||||
throw new RuntimeException('Retry após perda da confirmação não foi idempotente.');
|
||||
}
|
||||
$races[] = ['scenario' => 'process_exit_after_commit_before_acknowledgement', 'result' => 'passed', 'facts' => 1, 'checkpoint_version' => 2];
|
||||
} finally {
|
||||
DB::purge('inbox_proof_read');
|
||||
}
|
||||
|
||||
return $races;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
// Runs only inside the disposable reproduction after its authorised lifecycle.
|
||||
// Inherits the explicit test scope/clients; never creates grants in the host.
|
||||
$catalogue=$call($actor,'/api/accesses');
|
||||
$check($catalogue['status']===200&&in_array('purchasing_requisitions.prepare',$catalogue['data']['catalogue']['unit'],true),'native administration receives complete unit catalogue');
|
||||
$check(($catalogue['data']['catalogue_labels']['purchasing_requisitions.prepare']??'')==='Preparar e submeter requisições','native administration receives canonical server labels');
|
||||
$check($call($head,'/api/accesses')['status']===403,'reviewer cannot inspect administrative catalogue without management right');
|
||||
$check($call($actor,'/api/accesses','POST',['email'=>\App\Models\User::findOrFail($head)->email,'unit_id'=>$scope['unit_id'],'permission'=>'purchasing_budget.consume','reason'=>'Forbidden delegation outside own synthetic authority'])['status']===403,'forged delegation of an unowned catalogue right refused');
|
||||
|
||||
$uiManifest=json_decode(file_get_contents($clone.'/public/build/manifest.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
foreach($uiManifest as $chunk)foreach([$chunk['file'],...($chunk['css']??[])] as $file){
|
||||
$asset=$anonymous->get('http://127.0.0.1:'.$port.'/build/'.$file);
|
||||
$check($asset->getStatusCode()===200&&strlen((string)$asset->getBody())>100,'packaged frontend dependency served before authentication');
|
||||
}
|
||||
$main=$anonymous->get('http://127.0.0.1:'.$port.'/');
|
||||
$check($main->getStatusCode()===200&&str_contains((string)$main->getBody(),'/build/modules/internal-requisitions/assets/'),'native SGF entry renders the packaged frontend manifest');
|
||||
$check($anonymous->get('http://127.0.0.1:'.$port.'/build/modules/internal-requisitions/assets/missing.js')->getStatusCode()===404,'undeclared frontend asset refused');
|
||||
|
||||
// Reproduce destination-style broad inherited ACLs on NEW objects, then prove
|
||||
// the proposed map narrows them without touching any of the 87 prior tables.
|
||||
$beforeAcl=[];
|
||||
foreach($inventory['tables'] as $table)$beforeAcl[$table]=$db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl;
|
||||
foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::TABLES as $table=>$rights)$db->statement('GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
foreach(\Tempo\Purchasing\Delivery\SqlPrivileges::plan($runtime)['statements'] as $sql)$db->statement($sql);
|
||||
$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_centres','UPDATE') AS allowed")->allowed&&!$rt->selectOne("SELECT has_table_privilege(current_user,'cb_audit','DELETE') AS allowed")->allowed,'minimal plan removes inherited update and delete on new append-only sources');
|
||||
$check(!$rt->selectOne("SELECT has_table_privilege(current_user,'ir_requests','TRUNCATE') AS allowed")->allowed,'runtime cannot truncate requisitions');
|
||||
foreach($beforeAcl as $table=>$acl)$check($db->selectOne('SELECT relacl::text AS acl FROM pg_class WHERE oid=?::regclass',['public.'.$table])->acl===$acl,'existing table ACL preserved '.$table);
|
||||
try{$rt->transaction(fn()=>$rt->statement('CREATE TABLE forbidden_runtime_ddl (id integer)'));throw new LogicException('Unexpected runtime DDL');}
|
||||
catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime CREATE denied');}
|
||||
try{$rt->transaction(fn()=>$rt->statement('ALTER TABLE permission_grants ADD COLUMN forbidden_runtime integer'));throw new LogicException('Unexpected runtime ALTER');}
|
||||
catch(\Illuminate\Database\QueryException $e){$check($e->errorInfo[0]==='42501','actual runtime ALTER of identity catalogue denied');}
|
||||
|
||||
$badCentre=['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'code'=>'NOT-ALLOWED','reason'=>'Validation in the isolated reproduction only'];
|
||||
$check($call($head,'/api/internal-requisitions/centres','POST',$badCentre)['status']===403,'centre creation requires own configuration and budget rights');
|
||||
$check($call($actor,'/api/internal-requisitions/centres','POST',$badCentre+['company_id'=>999999])['status']===422,'centre context cannot be forged');
|
||||
$check(\Illuminate\Support\Facades\DB::table('cb_centres')->count()===1,'refused centre operations leave original source intact');
|
||||
foreach(['equipment','part'] as $unsupported){
|
||||
$r=$call($actor,'/api/internal-requisitions/save','POST',['operation_key'=>(string)\Illuminate\Support\Str::uuid(),'cost_center_id'=>$f['centre'],'priority'=>'normal','needed_by'=>date('Y-m-d',time()+864000),'justification'=>'Unsupported type must remain outside this increment.','items'=>[['description'=>'Unsupported source','category'=>'Excluded','purchase_type'=>$unsupported,'quantity'=>'1.000','unit'=>'UN','unit_price'=>'1.00','tax_rate'=>'0.00']]]);
|
||||
$check($r['status']===422,'unsupported requisition type refused '.$unsupported);
|
||||
}
|
||||
$data=json_decode(\Illuminate\Support\Facades\DB::table('ir_requests')->where('id',$id)->value('data'),true,32,JSON_THROW_ON_ERROR);
|
||||
$check(array_column($data['items'],'purchase_type')===['consumable','service','other'],'approved lifecycle covers the three supported basic types');
|
||||
$check(\Illuminate\Support\Facades\DB::table('ir_requests')->count()===1&&\Illuminate\Support\Facades\DB::table('cb_budget_entries')->where('kind','reserve')->count()===1,'unsupported types create neither a second request nor reservation');
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
// Source inspection and schema-only export. Never exports rows or changes source privileges.
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
$host = getenv('FIX_SOURCE_HOST');
|
||||
$output = getenv('FIX_SCHEMA_FILE');
|
||||
require $host.'/vendor/autoload.php';
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
try {
|
||||
$db = DB::connection();
|
||||
$db->beginTransaction();
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
|
||||
$acl = [];
|
||||
foreach ($tables as $table) {
|
||||
$rights = [];
|
||||
foreach (['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REFERENCES', 'TRIGGER', 'TRUNCATE'] as $right) {
|
||||
if ($db->selectOne('select has_table_privilege(current_user,?,?) as allowed', ['public.'.$table, $right])->allowed) {
|
||||
$rights[] = $right;
|
||||
}
|
||||
}$acl[$table] = $rights;
|
||||
}
|
||||
$fingerprints = [];
|
||||
foreach (['permission_grants', 'company_memberships', 'identity_audit'] as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as value from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'value'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$functions = $db->select("select p.oid::regprocedure::text as identity from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and has_function_privilege(current_user,p.oid,'EXECUTE') order by 1");
|
||||
$config = $db->getConfig();
|
||||
$db->rollBack();
|
||||
if (getenv('FIX_CHECK_ONLY') === '1') {
|
||||
echo json_encode(['source_security_fingerprints' => $fingerprints, 'tables' => $tables, 'database' => $config['database'], 'read_only' => true], JSON_THROW_ON_ERROR);
|
||||
exit(0);
|
||||
}
|
||||
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--schema-only', '--format=custom', '--no-owner', '--no-privileges', '--no-comments', '--file='.$output, '--dbname='.$config['database']], null,
|
||||
['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGOPTIONS' => '-c default_transaction_read_only=on'], null, 60);
|
||||
$dump->mustRun();
|
||||
echo json_encode(['database' => $config['database'], 'tables' => $tables, 'runtime_table_acl' => $acl, 'runtime_functions' => array_column($functions, 'identity'), 'source_security_fingerprints' => $fingerprints, 'schema_sha256' => hash_file('sha256', $output), 'rows_exported' => 0, 'read_only' => true], JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $error) {
|
||||
if (isset($db) && $db->transactionLevel() > 0) {
|
||||
$db->rollBack();
|
||||
}fwrite(STDERR, 'Schema-only export failed: '.get_class($error).($error instanceof QueryException ? ' SQLSTATE '.($error->errorInfo[0] ?? '').'; '.$error->getSql() : '')."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Purchasing\Delivery\CodeDeployment;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
use Tempo\Purchasing\Delivery\SqlPrivileges;
|
||||
|
||||
$original = getenv('FIX_SOURCE_HOST');
|
||||
$clone = getenv('FIX_CLONE');
|
||||
$root = getenv('FIX_ROOT');
|
||||
require $clone.'/vendor/autoload.php';
|
||||
$app = require $clone.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$bundle = getenv('FIX_BUNDLE');
|
||||
$work = getenv('FIX_WORK');
|
||||
$runtime = getenv('FIX_RUNTIME');
|
||||
$runtimePassword = getenv('FIX_RUNTIME_PASSWORD');
|
||||
require $bundle.'/integrations/sgf/operational/bootstrap.php';
|
||||
require $bundle.'/integrations/sgf/operational/CodeDeployment.php';
|
||||
$inventory = json_decode(file_get_contents($work.'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$db = DB::connection();
|
||||
if (! preg_match('/^sgf_reproduction_([a-f0-9]{12})$/D', $db->getDatabaseName(), $identity) || $runtime !== 'fix_app_'.$identity[1] || $db->selectOne('select current_user as name')->name !== 'fix_owner_'.$identity[1]) {
|
||||
throw new RuntimeException('Disposable reproduction identity required before any test grant or write.');
|
||||
}
|
||||
$checks = [];
|
||||
$check = function ($ok, $label) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($label);
|
||||
}$checks[] = $label;
|
||||
};
|
||||
$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
|
||||
$check($tables === $inventory['tables'], 'reproduced current 87-table schema without source rows');
|
||||
$check(DB::table('users')->count() === 0 && DB::table('permission_grants')->count() === 0, 'no accounts or grants imported from destination');
|
||||
$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
|
||||
foreach ($inventory['runtime_table_acl'] as $table => $rights) {
|
||||
if ($rights) {
|
||||
$db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
}
|
||||
if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
|
||||
$seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
|
||||
if ($seq) {
|
||||
$db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
|
||||
foreach ($inventory['runtime_functions'] as $function) {
|
||||
$db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
|
||||
}
|
||||
config(['database.connections.fixed_runtime' => [...$db->getConfig(), 'url' => null, 'username' => $runtime, 'password' => $runtimePassword]]);
|
||||
$rt = DB::connection('fixed_runtime');
|
||||
$check(! $rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed, 'runtime cannot create schema objects');
|
||||
$now = now();
|
||||
$tenant = DB::table('tenants')->insertGetId(['name' => 'Empresa sintética da reprodução', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$company = DB::table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Horizonte sintético', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$unit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade sintética', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$password = bin2hex(random_bytes(24));
|
||||
$actor = User::create(['name' => 'Preparador sintético', 'email' => 'prepare-'.$unit.'@example.test', 'password' => $password])->id;
|
||||
$head = User::create(['name' => 'Aprovador sintético', 'email' => 'approve-'.$unit.'@example.test', 'password' => $password])->id;
|
||||
foreach ([$actor, $head] as $id) {
|
||||
DB::table('company_memberships')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company]);
|
||||
foreach (['company.view' => null, 'unit.view' => $unit] as $right => $u) {
|
||||
DB::table('permission_grants')->insert(['user_id' => $id, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $u, 'permission' => $right, 'created_at' => $now, 'updated_at' => $now]);
|
||||
}
|
||||
}
|
||||
DB::table('permission_grants')->insert(['user_id' => $actor, 'tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => null, 'permission' => 'access.manage', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$security = function () use ($db) {
|
||||
$out = [];
|
||||
foreach (['users', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
|
||||
$out[$table] = hash('sha256', json_encode($db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
$before = $security();
|
||||
$target = Installation::target($db);
|
||||
$fault = true;
|
||||
DB::listen(function ($query) use (&$fault) {
|
||||
if ($fault && str_contains($query->sql, 'create table "cb_ui_commands"')) {
|
||||
$fault = false;
|
||||
throw new RuntimeException('Injected corrected migration failure');
|
||||
}
|
||||
});
|
||||
try {
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
|
||||
throw new LogicException('Expected migration failure');
|
||||
} catch (RuntimeException$error) {
|
||||
$check($error->getMessage() === 'Injected corrected migration failure', 'injected dependency migration failure');
|
||||
}
|
||||
$check(! $db->getSchemaBuilder()->hasTable('ir_requests') && ! $db->getSchemaBuilder()->hasTable('cb_ui_commands') && $security() === $before, 'failed migration restores original schema and identity data');
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Isolated migrator');
|
||||
$check($security() === $before, 'successful migration preserves existing identity and grants');
|
||||
$check(DB::table('cb_centres')->count() === 0 && DB::table('cb_budgets')->count() === 0, 'installation creates empty centres and allocations');
|
||||
try {
|
||||
$rt->table('sgc_installations')->count();
|
||||
throw new LogicException('Unexpected module access');
|
||||
} catch (QueryException$error) {
|
||||
$check($error->errorInfo[0] === '42501', 'runtime module SQL access denied before test-only privilege plan');
|
||||
}
|
||||
$privileges = SqlPrivileges::plan($runtime);
|
||||
foreach ($privileges['statements'] as $sql) {
|
||||
$db->statement($sql);
|
||||
}
|
||||
$check(! $rt->selectOne("select has_table_privilege(current_user,'ir_events','UPDATE') as allowed")->allowed && ! $rt->selectOne("select has_table_privilege(current_user,'ir_requests','DELETE') as allowed")->allowed, 'minimal runtime excludes historical update and requisition deletion');
|
||||
$check($rt->table('sgc_installations')->value('revision') === 2, 'runtime reads corrected revision without ownership');
|
||||
$deploy = new CodeDeployment($clone);
|
||||
$mh = hash_file('sha256', $bundle.'/manifest.json');
|
||||
$plan = $deploy->plan($bundle, $mh);
|
||||
$applied = $deploy->apply($bundle, $mh, $plan['target_sha256'], 'Isolated code preparation', true);
|
||||
unlink($clone.'/storage/framework/down');
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>true,"target"=>getenv("IR_HTTP_TARGET"),"scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
file_put_contents($clone.'/config/purchasing-access.php', '<?php return ["initialisation_scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
$f = ['actor' => $actor, 'validator' => $head, 'scope' => ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit]];
|
||||
$scope = $f['scope'];
|
||||
$target = Installation::target(DB::connection());
|
||||
$scopeKey = implode(':', $scope);
|
||||
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [$scopeKey]]);
|
||||
$actor = $f['actor'];
|
||||
$head = $f['validator'];
|
||||
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'like', 'purchasing_%')->delete();
|
||||
$password = bin2hex(random_bytes(24));
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$u = User::findOrFail($id);
|
||||
$u->password = $password;
|
||||
$u->save();
|
||||
}
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$port = (int) substr(strrchr($address, ':'), 1);
|
||||
$server = new Process([PHP_BINARY, '-S', '127.0.0.1:'.$port, '-t', $clone.'/public', $root.'/tests/Support/sgf-composition-router.php'], $clone, ['IR_CLONE' => $clone, 'DB_USERNAME' => $runtime, 'DB_PASSWORD' => $runtimePassword, 'IR_HTTP_ENABLED' => '1', 'IR_HTTP_TARGET' => $target, 'IR_HTTP_SCOPE' => $scopeKey], null, 180);
|
||||
$server->start();
|
||||
$earlyChecks = $checks;
|
||||
$check = function ($ok, $label) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($label);
|
||||
}$checks[] = $label;
|
||||
};
|
||||
$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(AppServiceProvider::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'provider really loaded from isolated copy');
|
||||
$plan = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $original.'/scripts/sgf.ps1', 'php', $clone.'/packages/internal-requisitions/scripts/sgf-internal-install.php', 'plan', $clone], $clone, null, null, 60);
|
||||
$plan->mustRun();
|
||||
$check(json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR)['schema_prepared'], 'new bundle bootstrap accepted by installer integrity check');
|
||||
try {
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$clients = [];
|
||||
$anonymous = new Client(['http_errors' => false]);
|
||||
$check($anonymous->get('http://127.0.0.1:'.$port.'/purchasing/internal-requisitions/access-initialisation', ['headers' => ['Accept' => 'application/json']])->getStatusCode() === 401, 'anonymous direct initialisation URL denied');
|
||||
$call = function ($id, $path, $method = 'GET', $data = null, $headers = []) use (&$clients, $port) {
|
||||
$c = &$clients[$id];
|
||||
$opts = ['http_errors' => false, 'headers' => array_merge(['Accept' => 'application/json', 'X-CSRF-TOKEN' => $c['csrf'], 'X-SGF-Context' => $c['token'] ?? '', 'X-Purchasing-Context' => 'procurement.internal'], $headers)];
|
||||
if ($data !== null) {
|
||||
$opts['json'] = $data;
|
||||
}
|
||||
$r = $c['http']->request($method, 'http://127.0.0.1:'.$port.$path, $opts);
|
||||
$body = (string) $r->getBody();
|
||||
|
||||
return ['status' => $r->getStatusCode(), 'data' => json_decode($body, true), 'body' => $body];
|
||||
};
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$http = new Client(['cookies' => new CookieJar, 'timeout' => 15]);
|
||||
$s = json_decode((string) $http->get('http://127.0.0.1:'.$port.'/api/session', ['headers' => ['Accept' => 'application/json']])->getBody(), true);
|
||||
$clients[$id] = ['http' => $http, 'csrf' => $s['csrf']];
|
||||
$login = $call($id, '/api/login', 'POST', ['email' => User::findOrFail($id)->email, 'password' => $password]);
|
||||
$check($login['status'] === 200, 'native clone authentication '.$id);
|
||||
$clients[$id]['csrf'] = $login['data']['csrf'];
|
||||
$selected = $call($id, '/api/context', 'POST', $scope);
|
||||
$check($selected['status'] === 200, 'native clone context '.$id);
|
||||
$clients[$id]['token'] = $selected['data']['context']['token'];
|
||||
}
|
||||
$page = $call($actor, '/purchasing/internal-requisitions/access-initialisation');
|
||||
$check($page['status'] === 200 && str_contains($page['body'], 'Administrador nominativo'), 'provider registers initial authority page without prior functional rights');
|
||||
$initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega sintética através da composição HTTP.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.record', 'purchasing_budget.view', 'purchasing_budget.create', 'purchasing_budget.approve']]]];
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial, ['X-CSRF-TOKEN' => 'bad'])['status'] === 419, 'initial authority CSRF enforced');
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial)['status'] === 200, 'initial authority through normal middleware');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 409, 'work page requires explicit module selection');
|
||||
$oldToken = $clients[$actor]['token'];
|
||||
$selected = $call($actor, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$check($selected['status'] === 200, 'new requisition view right makes purchasing selectable');
|
||||
$clients[$actor]['token'] = $selected['data']['context']['token'];
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-SGF-Context' => $oldToken])['status'] === 409, 'stale module generation refused');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 200, 'provider registers operational work page');
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-Purchasing-Context' => 'procurement.third_party'])['status'] === 403, 'different enterprise option refused by operational composition');
|
||||
foreach (['purchasing_requisitions.view', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.view', 'purchasing_budget.approve'] as $right) {
|
||||
$r = $call($actor, '/api/accesses', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => $right, 'reason' => 'Atribuição sintética explícita.']);
|
||||
$check($r['status'] === 200, 'native administrative delegation '.$right);
|
||||
}
|
||||
$selected = $call($head, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$clients[$head]['token'] = $selected['data']['context']['token'];
|
||||
$check($call($actor, '/purchasing/internal-requisitions/budget-setup')['status'] === 200, 'empty installation exposes authorised centre and budget preparation');
|
||||
$centreCommand = ['operation_key' => (string) Str::uuid(), 'code' => 'SINTETICO-REPRO', 'reason' => 'Centro exclusivamente sintético da reprodução isolada.'];
|
||||
$centre = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
|
||||
$check($centre['status'] === 200, 'centre created through authorised UI API with runtime role');
|
||||
$f['centre'] = $centre['data']['centre_id'];
|
||||
$repeat = $call($actor, '/api/internal-requisitions/centres', 'POST', $centreCommand);
|
||||
$check($repeat['status'] === 200 && $repeat['data']['replayed'] && DB::table('cb_centres')->count() === 1, 'centre retries preserve one source');
|
||||
$budgetData = ['cost_center_id' => $f['centre'], 'year' => (int) date('Y'), 'kind' => 'operating', 'includes_tax' => true, 'amount' => '10000.00', 'reason' => 'Dotação exclusivamente sintética, sem valores de destino.'];
|
||||
$prepared = $call($actor, '/api/purchasing-budget/create', 'POST', $budgetData, ['Idempotency-Key' => (string) Str::uuid()]);
|
||||
$check($prepared['status'] === 200, 'existing budget API prepares synthetic allocation');
|
||||
$budgetId = DB::table('cb_budgets')->value('id');
|
||||
$check($call($actor, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()])['status'] !== 200, 'budget approval requires independent identity');
|
||||
$approvedBudget = $call($head, '/api/purchasing-budget/approve', 'POST', ['budget_id' => $budgetId], ['Idempotency-Key' => (string) Str::uuid()]);
|
||||
$check($approvedBudget['status'] === 200, 'independent allocation approval uses existing budget kernel');
|
||||
$list = $call($actor, '/api/purchasing-budget');
|
||||
$check($list['status'] === 200 && count($list['data']['centres']) === 1, 'workspace dependencies available on reproduced schema');
|
||||
$check($call($actor, '/api/purchasing-budget/movement', 'POST', [], ['Idempotency-Key' => (string) Str::uuid()])['status'] === 403, 'direct budget movements cannot bypass requisition lifecycle');
|
||||
$policy = ['version' => 0, 'reason' => 'Política sintética na composição integrada.', 'enabled' => true, 'budget_required' => true, 'first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'approval_includes_tax' => true, 'centres' => [['id' => $f['centre'], 'department_ref' => 'OPER']], 'members' => [['user_id' => $actor, 'department_ref' => 'OPER'], ['user_id' => $head, 'department_ref' => 'OPER']]];
|
||||
$check($call($actor, '/api/internal-requisitions/policy', 'POST', $policy)['status'] === 200, 'operational policy configured via HTTP');
|
||||
$draft = $call($actor, '/api/internal-requisitions/save', 'POST', ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $f['centre'], 'priority' => 'normal', 'needed_by' => date('Y-m-d', time() + 864000), 'justification' => 'Requisição sintética da composição.', 'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00'], ['description'=>'Servi?o sint?tico','category'=>'Servi?os','purchase_type'=>'service','quantity'=>'1.000','unit'=>'UN','unit_price'=>'25.00','tax_rate'=>'0.00'], ['description'=>'Outro sint?tico','category'=>'Outros','purchase_type'=>'other','quantity'=>'1.000','unit'=>'UN','unit_price'=>'10.00','tax_rate'=>'0.00']]]);
|
||||
$check($draft['status'] === 200, 'integrated operational draft');
|
||||
$id = $draft['data']['id'];
|
||||
$submitted = $call($actor, "/api/internal-requisitions/$id/submit", 'POST', ['operation_key' => (string) Str::uuid(), 'version' => 1]);
|
||||
$check($submitted['status'] === 200 && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'integrated submit uses existing budget adapter');
|
||||
$decision = ['operation_key' => (string) Str::uuid(), 'version' => $submitted['data']['version'], 'decision' => 'approved'];
|
||||
$check($call($actor, "/api/internal-requisitions/$id/decide", 'POST', $decision)['status'] === 403, 'integrated independent identity enforced');
|
||||
$approved = $call($head, "/api/internal-requisitions/$id/decide", 'POST', $decision);
|
||||
$check($approved['status'] === 200 && $approved['data']['status'] === 'approved', 'integrated approval by another identity');
|
||||
$view = $call($actor, "/api/internal-requisitions/$id");
|
||||
$check($view['status'] === 200 && count($view['data']['history']) === 3, 'integrated authorised consultation and history');
|
||||
require __DIR__.'/sgf-admin-candidate-checks.php';
|
||||
$revoke = $call($actor, '/api/accesses/revoke', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => 'purchasing_requisitions.view', 'reason' => 'Revogação sintética explícita.']);
|
||||
$check($revoke['status'] === 200 && $call($head, "/api/internal-requisitions/$id")['status'] === 403, 'native revocation applies to direct module URL');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>[]];');
|
||||
// Deployment configuration changes require restarting persistent PHP services.
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}
|
||||
usleep(50000);
|
||||
}
|
||||
$check($call($actor, "/api/internal-requisitions/$id")['status'] === 403, 'uncontracted module refused at server');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>["purchasing"]];');
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>false];');
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 404, 'disabled composition exposes no executable work page');
|
||||
$fingerprints = [];
|
||||
foreach (array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename') as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$backup = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.$work.'/installed.dump', '--dbname='.$db->getDatabaseName()], null, ['PGHOST' => $db->getConfig('host'), 'PGPORT' => (string) $db->getConfig('port'), 'PGUSER' => $db->getConfig('username'), 'PGPASSWORD' => $db->getConfig('password')], null, 60);
|
||||
$backup->mustRun();
|
||||
file_put_contents($clone.'/storage/framework/down', '{"status":503}');
|
||||
$server->stop(1);
|
||||
require __DIR__.'/sgf-corrected-update.php';
|
||||
$deploy->rollback($applied['deployment_id'], 'Isolated corrected code recovery', true);
|
||||
$check(DB::table('ir_requests')->where('id', $id)->value('status') === 'approved' && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'code recovery preserves submitted origin decision and reservation');
|
||||
$check(! is_dir($clone.'/packages/internal-requisitions') && is_file($clone.'/storage/framework/down'), 'code recovery leaves baseline and maintenance intact');
|
||||
echo json_encode(['passed' => true, 'checks' => $checks, 'clone' => $clone, 'data_fingerprints' => $fingerprints, 'destination_activated' => false, 'normal_provider_routes' => true, 'browser_used' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
} finally {
|
||||
$server->stop(1);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
use Tempo\Purchasing\Delivery\SqlPrivileges;
|
||||
|
||||
require getenv('FIX_CLONE').'/vendor/autoload.php';
|
||||
$app = require getenv('FIX_CLONE').'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$bundle = getenv('FIX_BUNDLE');
|
||||
require $bundle.'/integrations/sgf/operational/bootstrap.php';
|
||||
$db = DB::connection();
|
||||
$runtime = getenv('FIX_RUNTIME');
|
||||
if (! preg_match('/^sgf_reproduction_restore_[a-f0-9]{12}$/D', $db->getDatabaseName()) || ! preg_match('/^fix_app_[a-f0-9]{12}$/D', $runtime)) {
|
||||
throw new RuntimeException('Isolated recovery target required.');
|
||||
}
|
||||
$baseline = json_decode(file_get_contents(getenv('FIX_WORK').'/baseline.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$q = fn ($name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$db->statement('GRANT USAGE ON SCHEMA public TO '.$q($runtime));
|
||||
foreach ($baseline['runtime_table_acl'] as $table => $rights) {
|
||||
if ($rights) {
|
||||
$db->statement('GRANT '.implode(', ', $rights).' ON TABLE public.'.$q($table).' TO '.$q($runtime));
|
||||
}
|
||||
if (in_array('INSERT', $rights, true) && $db->getSchemaBuilder()->hasColumn($table, 'id')) {
|
||||
$seq = $db->selectOne('select pg_get_serial_sequence(?,?) as name', ['public.'.$table, 'id'])->name;
|
||||
if ($seq) {
|
||||
$db->statement('GRANT USAGE ON SEQUENCE '.$seq.' TO '.$q($runtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
$db->statement('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC');
|
||||
foreach ($baseline['runtime_functions'] as $function) {
|
||||
$db->statement('GRANT EXECUTE ON FUNCTION '.$function.' TO '.$q($runtime));
|
||||
}
|
||||
foreach (SqlPrivileges::plan($runtime)['statements'] as $sql) {
|
||||
$db->statement($sql);
|
||||
}
|
||||
$expected = json_decode(file_get_contents(getenv('FIX_WORK').'/expected.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$fingerprints = [];
|
||||
foreach (array_keys($expected) as $table) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
$fingerprints[$table] = hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
if ($fingerprints !== $expected) {
|
||||
throw new RuntimeException('Recovered data differs.');
|
||||
}
|
||||
config(['database.connections.recovered_runtime' => [...$db->getConfig(), 'username' => $runtime, 'password' => getenv('FIX_RUNTIME_PASSWORD'), 'url' => null]]);
|
||||
$rt = DB::connection('recovered_runtime');
|
||||
if ($rt->table('ir_requests')->value('status') !== 'approved' || $rt->table('cb_budget_entries')->where('kind', 'reserve')->count() !== 1) {
|
||||
throw new RuntimeException('Recovered runtime cannot consult original decision or reservation.');
|
||||
}
|
||||
if ($rt->selectOne("select has_schema_privilege(current_user,'public','CREATE') as allowed")->allowed) {
|
||||
throw new RuntimeException('Recovery escalated runtime role.');
|
||||
}
|
||||
$receipt = $rt->table('sgc_installations')->first();
|
||||
if ($receipt->schema_signature !== Installation::signature($rt, Installation::tables())) {
|
||||
throw new RuntimeException('Recovered schema differs.');
|
||||
}
|
||||
echo json_encode(['verified' => true, 'tables_reconciled' => count($fingerprints), 'approved_origin_reservation_versions_preserved' => true, 'runtime_read_verified' => true, 'runtime_ddl_denied' => true, 'schema_and_triggers_verified' => true, 'sql_acl_reapplied_only_to_isolated_role' => true], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
// Code-only successor for the disposable reproduction, never a release candidate.
|
||||
$variant = $work.'/code-only-successor';
|
||||
$vm = json_decode(file_get_contents($bundle.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ([...array_keys($vm['support_files']), 'manifest.json'] as $file) {
|
||||
if (! is_dir(dirname($variant.'/'.$file))) mkdir(dirname($variant.'/'.$file), 0700, true);
|
||||
copy($bundle.'/'.$file, $variant.'/'.$file);
|
||||
}
|
||||
$file = 'integrations/sgf/internal-requisitions/screen.php';
|
||||
file_put_contents($variant.'/'.$file, "\n<!-- isolated code-only successor -->\n", FILE_APPEND);
|
||||
$vm['support_files'][$file] = hash_file('sha256', $variant.'/'.$file);
|
||||
file_put_contents($variant.'/manifest.json', json_encode($vm, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
$vh = hash_file('sha256', $variant.'/manifest.json');
|
||||
$vp = $deploy->plan($variant, $vh);
|
||||
$vu = $deploy->apply($variant, $vh, $vp['target_sha256'], 'Isolated code-only update', true);
|
||||
$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === $vh, 'code-only successor installed over existing provenance');
|
||||
$deploy->rollback($vu['deployment_id'], 'Isolated successor recovery', true);
|
||||
$check(hash_file('sha256', $clone.'/packages/internal-requisitions/manifest.json') === hash_file('sha256', $bundle.'/manifest.json'), 'code-only successor rollback restores exact candidate');
|
||||
foreach ($fingerprints as $table => $expected) {
|
||||
$rows = $db->select('select to_jsonb(t)::text as data from "'.$table.'" t order by to_jsonb(t)::text');
|
||||
if (hash('sha256', json_encode(array_column($rows, 'data'), JSON_THROW_ON_ERROR)) !== $expected) throw new RuntimeException('Update changed '.$table);
|
||||
}
|
||||
$check(true, 'code update and rollback preserve every reproduced data table');
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$host=getenv('SOURCING_CLONE');$work=getenv('SOURCING_WORK');require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
|
||||
$fixture=json_decode(file_get_contents($work.'/fixture.json'),true);$command=$argv[1];
|
||||
if(in_array($command,['revoke-evaluate','restore-evaluate','revoke-propose','restore-propose','revoke-view','restore-view'])){
|
||||
$right=str_contains($command,'evaluate')?'purchasing_sourcing.evaluate':(str_contains($command,'propose')?'purchasing_sourcing.propose':'purchasing_requisitions.view');$id=str_contains($command,'evaluate')?$fixture['user_id']:(str_contains($command,'propose')?2:3);
|
||||
$scope=['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$right];
|
||||
if(str_starts_with($command,'revoke'))$db->table('permission_grants')->where($scope)->delete();else $db->table('permission_grants')->insertOrIgnore($scope+['created_at'=>now(),'updated_at'=>now()]);
|
||||
}elseif($command==='fail-on')file_put_contents($host.'/storage/framework/sourcing-failure.fixture','isolated failure injection');
|
||||
elseif($command==='fail-off')unlink($host.'/storage/framework/sourcing-failure.fixture');
|
||||
else throw new RuntimeException('Unsupported fixture control');
|
||||
echo json_encode(['ok'=>true]);
|
||||
}catch(Throwable $e){fwrite(STDERR,$e->getMessage());exit(1);}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
use Tempo\Purchasing\SgfSourcing\Schema;
|
||||
use Tempo\Purchasing\Delivery\CodeDeployment;
|
||||
$host=getenv('SOURCING_CLONE');$root=getenv('SOURCING_REPO');$work=getenv('SOURCING_WORK');
|
||||
require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();$name=$db->selectOne('select current_database() as db')->db;
|
||||
if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$name)||$name!==getenv('SOURCING_DB')||!str_contains(str_replace('\\','/',$host),'/private/sourcing-')||str_replace('\\','/',$app->basePath())!==$host)throw new RuntimeException('Not an isolated sourcing copy');
|
||||
$candidate=json_decode(file_get_contents($root.'/docs/proofs/sgf-sourcing-candidate.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
if(!class_exists(Schema::class))require_once $candidate['directory'].'/integrations/sgf/sourcing/Schema.php';require_once $root.'/integrations/sgf/operational/CodeDeployment.php';
|
||||
$deploy=new CodeDeployment($host);$checks=[];$check=function($ok,$label)use(&$checks){if(!$ok)throw new RuntimeException($label);$checks[]=$label;};
|
||||
$fingerprint=function(string $table)use($db){return hash('sha256',json_encode($db->table($table)->orderBy('id')->get(),JSON_THROW_ON_ERROR));};
|
||||
if(($argv[1]??'')==='setup'){
|
||||
$baseline=['request'=>$fingerprint('ir_requests'),'budget'=>$fingerprint('cb_budget_entries'),'consumption'=>$fingerprint('cb_budget_consumptions'),'grants_count'=>$db->table('permission_grants')->count()];file_put_contents($work.'/origin.json',json_encode($baseline));
|
||||
$armed=true;$db->listen(function($query)use(&$armed){if($armed&&str_contains(strtolower($query->sql),'create table "ss_events"')){$armed=false;throw new RuntimeException('Injected migration failure');}});
|
||||
try{Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated failure proof');throw new RuntimeException('Expected failure');}catch(RuntimeException $e){$check($e->getMessage()==='Injected migration failure','Injected migration failure surfaced');}
|
||||
$check(!$db->getSchemaBuilder()->hasTable('ss_suppliers')&&!$db->table('sgc_installations')->where('module','sourcing')->exists(),'Migration failure rolls back tables and catalogue');
|
||||
$migration=Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated sourcing proof');$check($migration['prepared'],'Additive migration accepted');$check(Schema::migrate($db,hash_file('sha256',$work.'/before.dump'),'Isolated replay')['replayed'],'Migration replay is stable');
|
||||
$old=json_decode(file_get_contents($host.'/packages/internal-requisitions/manifest.json'),true);$next=json_decode(file_get_contents($candidate['directory'].'/manifest.json'),true);
|
||||
$current=json_decode(file_get_contents($host.'/storage/app/private/module-deployments/current.json'),true);
|
||||
try{$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);throw new RuntimeException('Unprepared plan accepted');}catch(RuntimeException $e){$check(str_contains($e->getMessage(),'migração própria'),'Schema-changing update refuses missing migration receipt');}
|
||||
$receipt=['from_manifest_sha256'=>$current['manifest_sha256'],'to_manifest_sha256'=>$candidate['archive']['manifest_sha256'],'host'=>$host,'schema_verified'=>Schema::compatible($db),'changes'=>[]];
|
||||
foreach($next['support_files'] as $file=>$hash)if(($old['support_files'][$file]??null)!==$hash)$receipt['changes'][$file]=['before'=>$old['support_files'][$file]??null,'after'=>$hash];
|
||||
file_put_contents($host.'/storage/framework/down','{}');
|
||||
$cli=new Symfony\Component\Process\Process([PHP_BINARY,$candidate['directory'].'/scripts/sgf-sourcing-install.php','migrate',$host,Tempo\Purchasing\Delivery\Installation::target($db),$work.'/before.dump',hash_file('sha256',$work.'/before.dump'),'Isolated migration CLI',$candidate['archive']['manifest_sha256']],$host,['PG_RESTORE_BINARY'=>'C:/Program Files/PostgreSQL/17/bin/pg_restore.exe']);$cli->mustRun();
|
||||
$cliResult=json_decode($cli->getOutput(),true,32,JSON_THROW_ON_ERROR);$check($cliResult['prepared']&&!$cliResult['activated']&&!$cliResult['rights_granted'],'Packaged migration CLI produces exact receipt without activation or grants');
|
||||
$plan=$deploy->plan($candidate['directory'],$candidate['archive']['manifest_sha256']);$applied=$deploy->apply($candidate['directory'],$candidate['archive']['manifest_sha256'],$plan['target_sha256'],'Isolated sourcing update',true);unlink($host.'/storage/framework/down');file_put_contents($work.'/applied.json',json_encode($applied));
|
||||
$target=Tempo\Purchasing\Delivery\Installation::target($db);file_put_contents($host.'/config/purchasing-operational.php','<?php return '.var_export(['enabled'=>true,'target'=>$target,'scopes'=>['1:1:1']],true).';');file_put_contents($host.'/config/purchasing-sourcing.php','<?php return ["enabled"=>true];');
|
||||
$private=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json'),true);
|
||||
$user=User::forceCreate(['name'=>'Synthetic isolated evaluator','email'=>'fixture-'.Str::uuid().'@example.invalid','password'=>$private['password'],'auth_version'=>1]);
|
||||
$db->table('company_memberships')->insert(['user_id'=>$user->id,'tenant_id'=>1,'company_id'=>1]);
|
||||
$departmentPolicy=$db->table('ir_policies')->where(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1])->first();$departmentPayload=json_decode($departmentPolicy->payload,true);$departmentPayload['members'][]=['user_id'=>$user->id,'department_ref'=>'OPER'];$db->table('ir_policies')->where('id',$departmentPolicy->id)->update(['payload'=>json_encode($departmentPayload)]); // Disposable configuration fixture only; submitted request snapshot is unchanged.
|
||||
$grants=[1=>['purchasing_sourcing.prepare'],2=>['purchasing_sourcing.evaluate','purchasing_sourcing.propose'],$user->id=>['company.view','unit.view','purchasing_requisitions.view','purchasing_sourcing.evaluate','purchasing_sourcing.confirm']];
|
||||
foreach($grants as $id=>$rights)foreach($rights as $right)$db->table('permission_grants')->insert(['user_id'=>$id,'tenant_id'=>1,'company_id'=>1,'unit_id'=>$right==='company.view'?null:1,'permission'=>$right,'created_at'=>now(),'updated_at'=>now()]);
|
||||
foreach([1,2,3] as $n)$db->table('ss_suppliers')->insert(['tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'reference'=>'SYNTHETIC-'.$n,'payload'=>json_encode(['name'=>'Fornecedor sintético '.$n,'active'=>true,'blocked'=>false,'review_until'=>'2027-12-31','version'=>1,'synthetic_only'=>true]),'created_at'=>now()]);
|
||||
file_put_contents($work.'/fixture.json',json_encode(['user_id'=>$user->id,'email'=>$user->email,'password'=>$private['password']]));
|
||||
echo json_encode(['passed'=>true,'checks'=>$checks,'fixture_user_only_in_disposable_copy'=>true,'deployment'=>$applied]);
|
||||
}elseif(($argv[1]??'')==='verify'){
|
||||
$baseline=json_decode(file_get_contents($work.'/origin.json'),true);foreach(['request'=>'ir_requests','budget'=>'cb_budget_entries','consumption'=>'cb_budget_consumptions'] as $key=>$table)$check($baseline[$key]===$fingerprint($table),'Origin '.$key.' unchanged');
|
||||
$check($db->table('ss_processes')->count()===1,'Exactly one sourcing process');$check($db->table('ss_commands')->count()===$db->table('ss_events')->count(),'One event per committed command');
|
||||
$saved=$db->table('ss_processes')->first();$state=json_decode($saved->payload,true);$check($state['confirmed']!==null,'Independent award confirmed in isolated copy');
|
||||
$before=$fingerprint('ss_events');$failed=false;try{$db->transaction(fn()=>$db->table('ss_events')->where('id',1)->update(['action'=>'tampered']));}catch(Throwable){$failed=true;}$check($failed&&$before===$fingerprint('ss_events'),'Database rejects event tampering');
|
||||
$applied=json_decode(file_get_contents($work.'/applied.json'),true);file_put_contents($host.'/storage/framework/down','{}');$rolled=$deploy->rollback($applied['deployment_id'],'Isolated sourcing code recovery',true);
|
||||
$check($state===json_decode($db->table('ss_processes')->first()->payload,true),'Code rollback preserves award data');
|
||||
echo json_encode(['passed'=>true,'checks'=>$checks,'code_recovery'=>$rolled,'old_code_requires_schema_recovery'=>true]);
|
||||
}else throw new RuntimeException('Use setup/verify');
|
||||
}catch(Throwable $e){fwrite(STDERR,get_class($e).': '.$e->getMessage()."\n");exit(1);}
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn,spawnSync} from 'node:child_process';
|
||||
const repo=process.env.SOURCING_REPO,work=process.env.SOURCING_WORK,host=process.env.SOURCING_CLONE,base='http://127.0.0.1:'+process.env.SOURCING_PORT,php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';
|
||||
assert(/^sgf_sourcing_test_[a-f0-9]{12}$/.test(process.env.SOURCING_DB));
|
||||
const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8')),fixture=JSON.parse(fs.readFileSync(work+'/fixture.json','utf8'));
|
||||
const clients={},checks=[];
|
||||
for(const p of [...privateData.profiles,{role:'decisor',email:fixture.email}]){
|
||||
const jar=new Map();let csrf='',token='';
|
||||
const api=async(path,method='GET',body,status=200,override={})=>{
|
||||
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':token,'X-Purchasing-Context':'procurement.internal',...override},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
||||
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,120)};}
|
||||
assert.equal(r.status,status,`${p.role} ${path}: ${d.message??JSON.stringify(d.errors)}`);if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;return d;
|
||||
};
|
||||
await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});clients[p.role]=api;
|
||||
}
|
||||
const read=who=>clients[who]('/api/sourcing/1');
|
||||
async function command(who,action,data,status=200){const current=await read(who);const payload={operation_key:crypto.randomUUID(),version:current.state.version,...(action==='propose'?{delivery_date:'2027-01-15',delivery_location:'Local de entrega exclusivamente sintético OPER'}:{}),...data};return {result:await clients[who]('/api/sourcing/1/'+action,'POST',payload,status),payload};}
|
||||
const prep='preparador',rev='revisor',dec='decisor',consult='consulta';
|
||||
for(const id of [1,2,3])await command(prep,'select',{supplier_id:id});
|
||||
const quote=(id,price='100.00',days=5)=>({supplier_id:id,reference:'SYNTHETIC-Q-'+id,currency:'MZN',valid_until:'2027-12-31',delivery_days:days,payment_terms:'Pagamento sintético a 30 dias',items:[{line:0,unit_price:price}]});
|
||||
for(const [id,amount] of [[1,'100.00'],[2,'110.00'],[3,'120.00']])await command(prep,'quote',{...quote(id,amount),delivery_days:'5',supplier_id:String(id)});
|
||||
const qSaved=await read(prep);assert.equal(qSaved.request.id,1);assert.equal(qSaved.request.total_minor,10000);assert(!qSaved.comparison.ready);
|
||||
for(const action of ['select','quote','evaluate','propose','confirm'])await clients[consult]('/api/sourcing/1/'+action,'POST',{},403);
|
||||
await clients[consult]('/api/sourcing/1','GET',undefined,409,{'X-SGF-Context':'bad'});await clients[consult]('/api/sourcing/1','GET',undefined,403,{'X-Purchasing-Context':'procurement.third_party'});await clients[consult]('/api/sourcing/9999999','GET',undefined,404);
|
||||
checks.push('Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected');
|
||||
const evalData=async(id,who)=>{const s=await read(who);return {supplier_id:id,quote_revision:s.state.quotes[id].revision,quality:8,history:8,payment:8,no_conflict:true,reason:'Synthetic independent evaluation for isolated test.'};};
|
||||
// Real concurrent database sessions, not just serial requests to PHP's development server.
|
||||
const initial=await read(rev),workerInput={actor_id:2,payload:{operation_key:crypto.randomUUID(),version:initial.state.version,...await evalData(1,rev)}};fs.writeFileSync(work+'/worker.json',JSON.stringify(workerInput));
|
||||
const worker=(file='worker.json')=>new Promise((resolve,reject)=>{const p=spawn(php,[repo+'/tests/Support/sgf-sourcing-worker.php',work+'/'+file],{cwd:host,env:process.env,windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('exit',code=>{try{if(code!==0)throw Error(err);resolve(JSON.parse(out));}catch(e){reject(e);}});});
|
||||
const workers=await Promise.all([worker(),worker()]);assert(workers.every(w=>w.status===200),JSON.stringify(workers));assert.deepEqual(workers[0].result,workers[1].result);checks.push('Concurrent identical commands return one committed version');
|
||||
const raceVersion=(await read(rev)).state.version;
|
||||
for(const [file,id] of [['race-a.json',2],['race-b.json',3]])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:2,payload:{operation_key:crypto.randomUUID(),version:raceVersion,...await evalData(id,rev)}}));
|
||||
const racing=await Promise.all([worker('race-a.json'),worker('race-b.json')]);assert.deepEqual(racing.map(r=>r.status).sort(),[200,409]);checks.push('Concurrent distinct commands use compare-and-swap; stale version refused');
|
||||
for(const id of [1,2,3]){if(id!==1)await command(rev,'evaluate',await evalData(id,rev));await command(dec,'evaluate',await evalData(id,dec));}
|
||||
let ready=await read(rev);assert(ready.comparison.ready);assert.deepEqual(ready.comparison.top_ids,[1]);
|
||||
const proposed=await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Best eligible synthetic quote with exact comparison version.'});
|
||||
await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload);checks.push('Proposal retry is idempotent');
|
||||
const confirmData=async()=>{const d=await read(dec);return {proposal_index:d.state.proposals.length-1,comparison_hash:d.state.proposals.at(-1).comparison_hash,reason:'Independent confirmation of the preserved comparison.'};};
|
||||
// The proposer does not possess confirm; independence is also tested in the shared core.
|
||||
await command(rev,'confirm',await confirmData(),403);
|
||||
await command(prep,'quote',quote(1,'100.00',6));await command(dec,'confirm',await confirmData(),409);checks.push('Changed quote blocks stale confirmation and invalidates evaluations');
|
||||
function control(name){const r=spawnSync(php,[repo+'/tests/Support/sgf-sourcing-controls.php',name],{cwd:host,env:process.env,encoding:'utf8',windowsHide:true});assert.equal(r.status,0,r.stderr);}
|
||||
for(const who of [rev,dec])await command(who,'evaluate',await evalData(1,who));
|
||||
ready=await read(rev);await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Re-proposed using current quote revision and comparison.'});
|
||||
control('revoke-evaluate');await command(dec,'confirm',await confirmData(),409);control('restore-evaluate');
|
||||
control('revoke-propose');await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload,403);control('restore-propose');
|
||||
control('revoke-view');await clients[consult]('/api/sourcing/1','GET',undefined,403);control('restore-view');checks.push('Revocation is effective for existing sessions, replay and qualified evaluations');
|
||||
const beforeFail=await read(prep);control('fail-on');await command(prep,'quote',quote(2,'110.00',7),500);control('fail-off');const afterFail=await read(prep);assert.equal(afterFail.state.version,beforeFail.state.version);assert.equal(afterFail.history.length,beforeFail.history.length);checks.push('Injected failure after event rolls back state, event and receipt');
|
||||
const confirmVersion=(await read(dec)).state.version,confirmation=await confirmData();
|
||||
for(const file of ['confirm-a.json','confirm-b.json'])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:fixture.user_id,action:'confirm',payload:{operation_key:crypto.randomUUID(),version:confirmVersion,...confirmation}}));
|
||||
const confirmations=await Promise.all([worker('confirm-a.json'),worker('confirm-b.json')]);assert.deepEqual(confirmations.map(r=>r.status).sort(),[200,409]);
|
||||
const confirmed=await read(consult);assert(confirmed.state.confirmed);assert.equal(confirmed.state.confirmed.confirmed_by,fixture.user_id);assert.equal(confirmed.state.confirmed.proposal.proposed_by,2);checks.push('Concurrent confirmations produce one immutable independent award');
|
||||
await command(prep,'quote',quote(1),409);checks.push('Independent confirmation references exact comparison and final state is immutable');
|
||||
const origin=await clients[consult]('/api/internal-requisitions/1');assert.equal(origin.record.version,3);assert.equal(origin.record.total_minor,10000);assert.equal(origin.history.length,3);checks.push('Accepted requisition read path remains intact');
|
||||
await clients[consult]('/purchasing/sourcing/1');await clients[consult]('/purchasing/sourcing/screen.mjs');checks.push('Authenticated read-only screen and module script served through protected routes');
|
||||
console.log(JSON.stringify({passed:true,checks,request_id:1,confirmed_only_in_isolated_copy:true,account_fixture_only_in_copy:true}));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
$host=getenv('SOURCING_CLONE');require $host.'/vendor/autoload.php';$app=require $host.'/bootstrap/app.php';$app->make(Kernel::class)->bootstrap();
|
||||
try{
|
||||
$db=DB::connection();if(!preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',$db->getDatabaseName()))throw new RuntimeException('Copy only');
|
||||
$input=json_decode(file_get_contents($argv[1]),true,32,JSON_THROW_ON_ERROR);$user=User::findOrFail($input['actor_id']);
|
||||
auth()->setUser($user);app(CompanyContext::class)->selectFor($user->id,1,1,1);
|
||||
$request=Request::create('/api/sourcing/1/evaluate','POST');$request->headers->set('X-Purchasing-Context','procurement.internal');$app->instance('request',$request);
|
||||
$result=(new Tempo\Purchasing\SgfSourcing\Workspace($user))->command(1,$input['action']??'evaluate',$input['payload']);
|
||||
echo json_encode(['status'=>200,'result'=>$result]);
|
||||
}catch(Throwable $e){echo json_encode(['status'=>$e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface?$e->getStatusCode():($e instanceof Tempo\Purchasing\Internal\RequisitionFailure?$e->status:500),'message'=>$e->getMessage()]);}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
function updateFixture(string $original, array $manifest, bool $native = false): string
|
||||
{
|
||||
$root = sys_get_temp_dir().'/sgf-update-'.bin2hex(random_bytes(6));
|
||||
mkdir($root, 0700);
|
||||
$copy = function (string $from, string $to, bool $links = false) use (&$copy) {
|
||||
if (! is_dir($to)) {
|
||||
mkdir($to, 0700, true);
|
||||
}
|
||||
foreach (new DirectoryIterator($from) as $file) {
|
||||
if ($file->isDot() || $file->isLink() || str_starts_with($file->getFilename(), '.')) {
|
||||
continue;
|
||||
}
|
||||
$target = $to.'/'.$file->getFilename();
|
||||
if ($file->isDir()) {
|
||||
$copy($file->getPathname(), $target, $links);
|
||||
} elseif (! $links || ! link($file->getPathname(), $target)) {
|
||||
copy($file->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
};
|
||||
if ($native) {
|
||||
foreach (['app', 'config', 'routes', 'packages', 'resources', 'public'] as $directory) {
|
||||
$copy($original.'/'.$directory, $root.'/'.$directory);
|
||||
}
|
||||
$old = json_decode(file_get_contents(dirname(__DIR__, 2).'/docs/proofs/sgf-composition-delivery.json'), true, 32, JSON_THROW_ON_ERROR)['clone'];
|
||||
$old = realpath($old) ?: '';
|
||||
$temp = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
$normal = str_replace('\\', '/', $old);
|
||||
$private = str_starts_with(strtolower($normal), strtolower($temp).'/sgf-composition-') && preg_match('/^sgf-composition-[a-f0-9]{12}$/D', basename($old));
|
||||
// Hardlinks only to an earlier disposable dependency copy, never to the actual destination.
|
||||
$copy(($private && is_dir($old.'/vendor')) ? $old.'/vendor' : $original.'/vendor', $root.'/vendor', $private);
|
||||
mkdir($root.'/bootstrap/cache', 0700, true);
|
||||
foreach (['app.php', 'providers.php'] as $file) {
|
||||
copy($original.'/bootstrap/'.$file, $root.'/bootstrap/'.$file);
|
||||
}
|
||||
copy($original.'/composer.json', $root.'/composer.json');
|
||||
copy($original.'/artisan', $root.'/artisan');
|
||||
if (!is_dir($root.'/public')) mkdir($root.'/public', 0700, true);
|
||||
copy($original.'/public/index.php', $root.'/public/index.php');
|
||||
} else {
|
||||
foreach ($manifest['host_overlays'] as $file => $values) {
|
||||
if (! is_dir(dirname($root.'/'.$file))) {
|
||||
mkdir(dirname($root.'/'.$file), 0700, true);
|
||||
}copy($original.'/'.$file, $root.'/'.$file);
|
||||
}
|
||||
}
|
||||
// Frontend overlays are existing host files too; preserve their exact baseline
|
||||
// in native copies without sharing mutable assets with the real destination.
|
||||
foreach ($manifest['host_overlays'] as $file => $values) {
|
||||
if (!is_file($root.'/'.$file)) {
|
||||
if (!is_dir(dirname($root.'/'.$file))) mkdir(dirname($root.'/'.$file),0700,true);
|
||||
copy($original.'/'.$file,$root.'/'.$file);
|
||||
}
|
||||
}
|
||||
foreach (['bootstrap/cache', 'packages/purchasing', 'storage/framework/views', 'storage/framework/cache', 'storage/framework/sessions', 'storage/logs'] as $directory) {
|
||||
if (! is_dir($root.'/'.$directory)) {
|
||||
mkdir($root.'/'.$directory, 0700, true);
|
||||
}
|
||||
}
|
||||
copy($original.'/packages/purchasing/current.json',$root.'/packages/purchasing/current.json');
|
||||
file_put_contents($root.'/storage/framework/down','{"status":503}');
|
||||
|
||||
return $root;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/InboxFixture.php';
|
||||
|
||||
if (getenv('WAREHOUSE_HTTP_FIXTURE') !== '1' || ($_SERVER['REMOTE_ADDR'] ?? null) !== '127.0.0.1') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
if ($path === '/health') {
|
||||
echo 'ready';
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (($_SERVER['HTTP_COOKIE'] ?? '') !== 'SGC_TEST_SESSION=synthetic-authorized') {
|
||||
http_response_code(401);
|
||||
echo '{"message":"synthetic login needed"}';
|
||||
exit;
|
||||
}
|
||||
if (! preg_match('~^/([a-z0-9_-]+)/stock/integracao/entregas$~D', $path, $match) || ($_SERVER['REQUEST_METHOD'] ?? null) !== 'GET') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$scenario = $match[1];
|
||||
if (preg_match('/^status_([0-9]{3})$/D', $scenario, $code)) {
|
||||
http_response_code((int) $code[1]);
|
||||
echo '{"message":"synthetic-private-body-do-not-log"}';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'redirect') {
|
||||
header('Location: /ok/stock/integracao/entregas', true, 302);
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'html') {
|
||||
header('Content-Type: text/html');
|
||||
echo '<html>synthetic-private-body-do-not-log</html>';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'json_error') {
|
||||
echo 'synthetic-private-body-do-not-log';
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'oversized') {
|
||||
echo str_repeat('x', 16384);
|
||||
exit;
|
||||
}
|
||||
if ($scenario === 'slow') {
|
||||
usleep(1200000);
|
||||
}
|
||||
$cursor = $_GET['cursor'] ?? null;
|
||||
$page = $scenario === 'paged' && $cursor !== null
|
||||
? inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')], 'cursor-two')
|
||||
: inboxPage([inboxEvent()], 'cursor-one', $scenario === 'paged');
|
||||
if ($scenario === 'wrong_tenant') {
|
||||
$page['items'][0]['tenant_id'] = 8;
|
||||
$page['items'][0] = inboxSign($page['items'][0]);
|
||||
}
|
||||
if ($scenario === 'bad_hash') {
|
||||
$page['items'][0]['lines'][0]['quantity_milli'] = 5000;
|
||||
}
|
||||
$page['fixture_query'] = ['cursor' => $cursor, 'limit' => $_GET['limit'] ?? null];
|
||||
echo json_encode($page, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
$file=realpath($argv[1]??'');$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));if(!$file||!$root||!str_starts_with($file,$root.DIRECTORY_SEPARATOR))throw new RuntimeException('Wrong native return worker input');
|
||||
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,current_setting('cluster_name') AS cluster,inet_server_port() AS port");
|
||||
if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->db,$m)||$i->role!=='stock_receipt_qa_'.$m[1]||(int)$i->port!==55439||$i->cluster!=='sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'))throw new RuntimeException('Wrong worker QA database');
|
||||
$p=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);$db->select("SELECT set_config('application_name',?,false)",[$p['application']]);$db->statement("SET lock_timeout='8s'");
|
||||
$actor=App\Models\User::findOrFail($p['actor']);auth()->guard()->setUser($actor);config(['stock_returns.enabled'=>true]);
|
||||
$action=$p['action']??'confirm';if(!in_array($action,['confirm','withdraw','reject'],true))throw new RuntimeException('Invalid worker action');
|
||||
$input=['version'=>1,'reason'=>'Synthetic concurrent native warehouse decision.']+($action==='confirm'?['received_in_warehouse'=>true]:[]);
|
||||
try{if($p['http']??false){$request=Illuminate\Http\Request::create('/api/stock-returns/'.$p['proposal'].'/'.$action,'POST',$input,[],[],['HTTP_ACCEPT'=>'application/json','HTTP_X_REQUESTED_WITH'=>'XMLHttpRequest']);$kernel=$app->make(Illuminate\Contracts\Http\Kernel::class);$response=$kernel->handle($request);$out=['status'=>$response->getStatusCode(),'result'=>json_decode($response->getContent(),true)];$kernel->terminate($request,$response);}else{$flow=app(App\Services\WarehouseReturns::class);$result=$action==='confirm'?$flow->confirm($actor,$p['proposal'],$input):$flow->close($actor,$p['proposal'],$input,$action==='withdraw'?'withdrawn':'rejected');$out=['status'=>200,'result'=>$result];}}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];}
|
||||
file_put_contents($file.'.result',json_encode($out,JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Integration\WarehouseHttpSource;
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
use Tempo\Integration\WarehousePull;
|
||||
use Tempo\Integration\WarehouseTransportFailure;
|
||||
|
||||
require_once __DIR__.'/../Support/InboxFixture.php';
|
||||
|
||||
function warehouseHttpFixture(): array
|
||||
{
|
||||
static $fixture;
|
||||
if ($fixture !== null) {
|
||||
return $fixture;
|
||||
}
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('Não foi possível reservar a porta de teste.');
|
||||
}
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$server = new Process([PHP_BINARY, '-S', $address, 'tests/Support/warehouse-http-router.php'], dirname(__DIR__, 2), ['WAREHOUSE_HTTP_FIXTURE' => '1'], timeout: null);
|
||||
$server->start();
|
||||
register_shutdown_function(fn () => $server->stop());
|
||||
$deadline = microtime(true) + 5;
|
||||
do {
|
||||
$probe = @stream_socket_client('tcp://'.$address, $errno, $error, 0.1);
|
||||
if (is_resource($probe)) {
|
||||
fclose($probe);
|
||||
|
||||
return $fixture = ['base' => 'http://'.$address, 'server' => $server];
|
||||
}
|
||||
usleep(20000);
|
||||
} while ($server->isRunning() && microtime(true) < $deadline);
|
||||
throw new RuntimeException('Servidor descartável HTTP indisponível.');
|
||||
}
|
||||
|
||||
function warehouseRemote(string $scenario = 'ok', ?Closure $credentials = null, int $timeout = 5000, int $maxBytes = 8388608): WarehouseHttpSource
|
||||
{
|
||||
return new WarehouseHttpSource(warehouseHttpFixture()['base'].'/'.$scenario, 7,
|
||||
$credentials ?? fn () => 'SGC_TEST_SESSION=synthetic-authorized', allowLoopbackHttp: true,
|
||||
timeoutMs: $timeout, connectTimeoutMs: min(1000, $timeout), maxResponseBytes: $maxBytes);
|
||||
}
|
||||
|
||||
test('HTTP source fixes the endpoint safely encodes query and obtains credentials for each read', function () {
|
||||
$calls = 0;
|
||||
$source = warehouseRemote(credentials: function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
return 'SGC_TEST_SESSION=synthetic-authorized';
|
||||
});
|
||||
$cursor = 'opaque&tenant_id=8#? +/=';
|
||||
$page = $source($cursor, 7);
|
||||
expect($page['schema'])->toBe('sgc.warehouse.fulfilment-feed.v1')
|
||||
->and($page['fixture_query'])->toBe(['cursor' => $cursor, 'limit' => '7']);
|
||||
$source(null, 1);
|
||||
expect($calls)->toBe(2)->and(print_r($source, true))->not->toContain('synthetic-authorized');
|
||||
});
|
||||
|
||||
test('real HTTP pages can be received durably in bounded runs without a stock engine', function () {
|
||||
$inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
|
||||
$pull = new WarehousePull($inbox);
|
||||
$source = warehouseRemote('paged');
|
||||
expect($pull->run($source, 1, 1))->toMatchArray(['inserted' => 1, 'pages' => 1, 'has_more' => true]);
|
||||
expect($pull->run($source, 5, 1))->toMatchArray(['inserted' => 1, 'pages' => 1, 'has_more' => false])
|
||||
->and($inbox->checkpoint()['last_event_id'])->toBe(2)
|
||||
->and($inbox->fact('sgc:7:warehouse-event:2')['delivery_event_key'])->toBe('sgc:7:warehouse-event:1');
|
||||
});
|
||||
|
||||
test('source refuses insecure or ambiguous origins and invalid parameters before credential retrieval', function () {
|
||||
$credentials = fn () => 'synthetic-private-secret';
|
||||
foreach (['http://example.invalid', 'http://localhost:8005', 'file:///tmp/source', 'https://user:synthetic-private-secret@example.invalid', 'https://example.invalid?token=synthetic-private-secret', 'https://example.invalid#token', 'https://example.invalid/../host', 'https://example.invalid/%2e%2e/host', 'https://example.invalid/a\\b', "https://example.invalid/\nheader"] as $url) {
|
||||
expect(fn () => new WarehouseHttpSource($url, 7, $credentials, allowLoopbackHttp: true))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
expect(fn () => new WarehouseHttpSource('http://127.0.0.1:8005', 7, $credentials))->toThrow(InvalidArgumentException::class);
|
||||
$calls = 0;
|
||||
$source = warehouseRemote(credentials: function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
return 'SGC_TEST_SESSION=synthetic-authorized';
|
||||
});
|
||||
foreach ([[null, 0], [null, 101], ['', 1], [str_repeat('c', 4097), 1]] as [$cursor, $limit]) {
|
||||
expect(fn () => $source($cursor, $limit))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
expect($calls)->toBe(0);
|
||||
});
|
||||
|
||||
test('expired sessions forbidden scopes and redirects return sanitised diagnostics', function (string $scenario, string $category, ?int $status, bool $retryable) {
|
||||
try {
|
||||
warehouseRemote($scenario)(null, 1);
|
||||
$this->fail('Era esperado erro de transporte.');
|
||||
} catch (WarehouseTransportFailure $e) {
|
||||
expect($e->category)->toBe($category)->and($e->httpStatus)->toBe($status)->and($e->retryable())->toBe($retryable)
|
||||
->and($e->getMessage())->not->toContain('synthetic-private')->not->toContain('SGC_TEST_SESSION')
|
||||
->and($e->getPrevious())->toBeNull();
|
||||
}
|
||||
})->with([
|
||||
['status_401', 'authentication', 401, false], ['status_419', 'authentication', 419, false],
|
||||
['status_403', 'authorization', 403, false], ['redirect', 'redirect', 302, false],
|
||||
['status_409', 'cursor', 409, false], ['status_422', 'cursor', 422, false],
|
||||
['status_429', 'remote_unavailable', 429, true], ['status_503', 'remote_unavailable', 503, true],
|
||||
['status_404', 'http', 404, false],
|
||||
]);
|
||||
|
||||
test('invalid or altered responses never advance the receiving checkpoint', function (string $scenario) {
|
||||
$inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
|
||||
expect(fn () => (new WarehousePull($inbox))->run(warehouseRemote($scenario)))->toThrow(WarehouseTransportFailure::class)
|
||||
->and($inbox->checkpoint())->toBe(['version' => 0, 'cursor' => null, 'last_event_id' => 0])
|
||||
->and($inbox->fact('sgc:7:warehouse-event:1'))->toBeNull();
|
||||
})->with(['html', 'json_error', 'wrong_tenant', 'bad_hash']);
|
||||
|
||||
test('credential failures and header injection expose no supplied secrets', function () {
|
||||
foreach ([fn () => throw new RuntimeException('synthetic-private-secret'), fn () => "session=synthetic-private-secret\r\nOther: injected", fn () => '', fn () => ['invalid' => 'synthetic-private-secret']] as $provider) {
|
||||
try {
|
||||
warehouseRemote(credentials: $provider)(null, 1);
|
||||
$this->fail('Era esperada recusa da credencial.');
|
||||
} catch (WarehouseTransportFailure $e) {
|
||||
expect($e->category)->toBe('credentials')->and($e->getMessage())->not->toContain('synthetic-private-secret')
|
||||
->and($e->getPrevious())->toBeNull();
|
||||
}
|
||||
}
|
||||
expect(fn () => warehouseRemote(credentials: fn () => 'SGC_TEST_SESSION=expired')(null, 1))->toThrow(WarehouseTransportFailure::class);
|
||||
});
|
||||
|
||||
test('connection refusal is diagnosed without losing the last receiving checkpoint', function () {
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$source = new WarehouseHttpSource('http://'.$address, 7, fn () => 'SGC_TEST_SESSION=synthetic-authorized', allowLoopbackHttp: true, timeoutMs: 1000, connectTimeoutMs: 200);
|
||||
$inbox = new WarehouseInbox(inboxDatabase(), 'synthetic-http-source', 7, 99, 'reader');
|
||||
try {
|
||||
(new WarehousePull($inbox))->run($source);
|
||||
$this->fail('Era esperada recusa da ligação.');
|
||||
} catch (WarehouseTransportFailure $e) {
|
||||
expect($e->category)->toBe('connection')->and($e->retryable())->toBeTrue()
|
||||
->and($inbox->checkpoint()['version'])->toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('oversized responses and timeouts stop the HTTP read without committing a page', function () {
|
||||
foreach ([warehouseRemote('oversized', maxBytes: 1024), warehouseRemote('slow', timeout: 200)] as $source) {
|
||||
try {
|
||||
$source(null, 1);
|
||||
$this->fail('Era esperado limite de transporte.');
|
||||
} catch (WarehouseTransportFailure $e) {
|
||||
expect(in_array($e->category, ['response_size', 'timeout'], true))->toBeTrue();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Integration\WarehouseFeed;
|
||||
use Tempo\Integration\WarehouseInbox;
|
||||
use Tempo\Integration\WarehousePull;
|
||||
|
||||
require_once __DIR__.'/../Support/InboxFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->db = inboxDatabase();
|
||||
$this->inbox = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
|
||||
});
|
||||
|
||||
test('receiver persists original facts and checkpoint together and safely acknowledges an exact retry', function () {
|
||||
expect($this->inbox->checkpoint())->toBe(['version' => 0, 'cursor' => null, 'last_event_id' => 0]);
|
||||
$delivery = inboxEvent();
|
||||
$receipt = inboxEvent(2, 'receive', $delivery['idempotency_key']);
|
||||
$page = inboxPage([$delivery, $receipt]);
|
||||
expect($this->inbox->accept($page, 0, null))->toMatchArray(['inserted' => 2, 'replayed' => false, 'version' => 1])
|
||||
->and($this->inbox->fact($delivery['idempotency_key']))->toBe($delivery)
|
||||
->and($this->inbox->fact($receipt['idempotency_key']))->toBe($receipt)
|
||||
->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => $page['next_cursor'], 'last_event_id' => 2]);
|
||||
expect($this->inbox->accept($page, 0, null))->toMatchArray(['inserted' => 0, 'replayed' => true, 'version' => 1])
|
||||
->and((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_facts')->fetchColumn())->toBe(2);
|
||||
$restarted = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
|
||||
expect($restarted->checkpoint())->toBe($this->inbox->checkpoint());
|
||||
});
|
||||
|
||||
test('receiver rolls back an entire page and cursor when a later receipt lacks its delivery', function () {
|
||||
$page = inboxPage([inboxEvent(), inboxEvent(3, 'receive', 'sgc:7:warehouse-event:2')]);
|
||||
expect(fn () => $this->inbox->accept($page, 0, null))->toThrow(RuntimeException::class)
|
||||
->and($this->inbox->checkpoint()['version'])->toBe(0)
|
||||
->and($this->inbox->fact('sgc:7:warehouse-event:1'))->toBeNull();
|
||||
$fixed = inboxPage([inboxEvent(), inboxEvent(2), inboxEvent(3, 'receive', 'sgc:7:warehouse-event:2')]);
|
||||
expect($this->inbox->accept($fixed, 0, null)['inserted'])->toBe(3);
|
||||
});
|
||||
|
||||
test('two readers cannot advance the same checkpoint with different pages', function () {
|
||||
$second = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'warehouse-reader');
|
||||
$before = $second->checkpoint();
|
||||
$this->inbox->accept(inboxPage([inboxEvent()], 'cursor-a'), 0, null);
|
||||
expect(fn () => $second->accept(inboxPage([inboxEvent(2)], 'cursor-b'), $before['version'], $before['cursor']))->toThrow(RuntimeException::class)
|
||||
->and($this->inbox->checkpoint()['cursor'])->toBe('cursor-a')
|
||||
->and($this->inbox->fact('sgc:7:warehouse-event:2'))->toBeNull();
|
||||
});
|
||||
|
||||
test('receiver refuses stale pages mismatched request cursors and conflicting event identities', function () {
|
||||
$this->inbox->accept(inboxPage([inboxEvent()]), 0, null);
|
||||
expect(fn () => $this->inbox->accept(inboxPage([inboxEvent(2)]), 1, 'another-cursor'))->toThrow(RuntimeException::class)
|
||||
->and(fn () => $this->inbox->accept(inboxPage([inboxEvent()]), 1, 'synthetic-opaque-cursor'))->toThrow(RuntimeException::class);
|
||||
$otherStream = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'another-reader');
|
||||
$conflict = inboxEvent();
|
||||
$conflict['lines'][0]['quantity_milli'] = 2500;
|
||||
expect(fn () => $otherStream->accept(inboxPage([inboxSign($conflict)]), 0, null))->toThrow(RuntimeException::class)
|
||||
->and($otherStream->checkpoint()['version'])->toBe(0)
|
||||
->and($this->inbox->fact('sgc:7:warehouse-event:1')['lines'][0]['quantity_milli'])->toBe(1500);
|
||||
});
|
||||
|
||||
test('origins destination companies and consumer checkpoints remain separate', function () {
|
||||
$page = inboxPage([inboxEvent()]);
|
||||
$this->inbox->accept($page, 0, null);
|
||||
foreach ([['synthetic-sgc-b', 7, 99], ['synthetic-sgc-a', 7, 100], ['synthetic-sgc-a', 8, 99]] as [$origin, $tenant, $destination]) {
|
||||
$other = new WarehouseInbox($this->db, $origin, $tenant, $destination, 'warehouse-reader');
|
||||
expect($other->fact('sgc:7:warehouse-event:1'))->toBeNull()->and($other->checkpoint()['version'])->toBe(0);
|
||||
}
|
||||
$otherStream = new WarehouseInbox($this->db, 'synthetic-sgc-a', 7, 99, 'another-reader');
|
||||
expect($otherStream->accept($page, 0, null)['inserted'])->toBe(0)
|
||||
->and($otherStream->checkpoint()['version'])->toBe(1);
|
||||
});
|
||||
|
||||
test('receipt must match the exact source request and cannot be acknowledged twice', function () {
|
||||
$delivery = inboxEvent();
|
||||
$this->inbox->accept(inboxPage([$delivery], 'cursor-1'), 0, null);
|
||||
$receipt = inboxEvent(2, 'receive', $delivery['idempotency_key']);
|
||||
$receipt['request']['destination'] = 'Outro destino';
|
||||
expect(fn () => $this->inbox->accept(inboxPage([inboxSign($receipt)]), 1, 'cursor-1'))->toThrow(RuntimeException::class);
|
||||
$this->inbox->accept(inboxPage([inboxEvent(2, 'receive', $delivery['idempotency_key'])], 'cursor-2'), 1, 'cursor-1');
|
||||
expect(fn () => $this->inbox->accept(inboxPage([inboxEvent(3, 'receive', $delivery['idempotency_key'])]), 2, 'cursor-2'))->toThrow(RuntimeException::class)
|
||||
->and($this->inbox->checkpoint()['version'])->toBe(2);
|
||||
});
|
||||
|
||||
test('empty polling pages remain resumable and do not invent a physical fact', function () {
|
||||
$result = $this->inbox->accept(inboxPage([], 'empty-cursor'), 0, null);
|
||||
expect($result['inserted'])->toBe(0)->and($result['has_more'])->toBeFalse()
|
||||
->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => 'empty-cursor', 'last_event_id' => 0]);
|
||||
expect($this->inbox->accept(inboxPage([inboxEvent()]), 1, 'empty-cursor')['inserted'])->toBe(1);
|
||||
});
|
||||
|
||||
test('malformed or altered feed data is refused before any receiving write', function () {
|
||||
$cases = [];
|
||||
foreach (['tenant_id' => 8, 'event_id' => '1', 'event_version' => 0, 'action' => 'authorize', 'occurred_at' => '2026-02-30T10:00:00+00:00', 'idempotency_key' => 'forged'] as $key => $value) {
|
||||
$event = inboxEvent();
|
||||
$event[$key] = $value;
|
||||
$cases[] = inboxPage([inboxSign($event)]);
|
||||
}
|
||||
$event = inboxEvent();
|
||||
$event['lines'][0]['quantity_milli'] = -1000;
|
||||
$cases[] = inboxPage([inboxSign($event)]);
|
||||
$event = inboxEvent();
|
||||
$event['lines'][0]['quantity_milli'] = 1001;
|
||||
$cases[] = inboxPage([$event]); // hash was not updated
|
||||
$event = inboxEvent();
|
||||
$event['lines'][0]['use'] = 'equipment';
|
||||
$cases[] = inboxPage([inboxSign($event)]);
|
||||
$cases[] = inboxPage([inboxEvent(2), inboxEvent()]);
|
||||
$cases[] = inboxPage([inboxEvent(), inboxEvent()]);
|
||||
$cases[] = inboxPage([], 'cursor', true);
|
||||
$cases[] = ['schema' => 'unknown', 'items' => [], 'next_cursor' => 'cursor', 'has_more' => false];
|
||||
foreach ($cases as $page) {
|
||||
expect(fn () => $this->inbox->accept($page, 0, null))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
expect((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_streams')->fetchColumn())->toBe(0)
|
||||
->and((int) $this->db->query('SELECT COUNT(*) FROM sgc_exchange_facts')->fetchColumn())->toBe(0);
|
||||
});
|
||||
|
||||
test('new compatible metadata is preserved and incompatible schemas are rejected', function () {
|
||||
$event = inboxEvent();
|
||||
$event['extra_metadata'] = ['reference' => 'Origem adicional sintética'];
|
||||
$event = inboxSign($event);
|
||||
expect($this->inbox->accept(inboxPage([$event]), 0, null)['inserted'])->toBe(1)
|
||||
->and($this->inbox->fact($event['idempotency_key'])['extra_metadata'])->toBe($event['extra_metadata']);
|
||||
$bad = inboxPage([]);
|
||||
$bad['schema'] = 'sgc.warehouse.fulfilment-feed.v2';
|
||||
expect(fn () => WarehouseFeed::validate($bad, 7))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('receiver never installs schema implicitly and refuses an externally owned transaction', function () {
|
||||
$empty = new PDO('sqlite::memory:');
|
||||
$inbox = new WarehouseInbox($empty, 'origin', 7, 99, 'reader');
|
||||
expect((int) $empty->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")->fetchColumn())->toBe(0);
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
expect(fn () => $this->inbox->accept(inboxPage([inboxEvent()]), 0, null))->toThrow(InvalidArgumentException::class)
|
||||
->and($this->db->inTransaction())->toBeTrue();
|
||||
} finally {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
});
|
||||
|
||||
test('bounded pull continues from the persisted cursor on the next execution', function () {
|
||||
$seen = [];
|
||||
$fetch = function (?string $cursor, int $size) use (&$seen): array {
|
||||
$seen[] = [$cursor, $size];
|
||||
|
||||
return $cursor === null ? inboxPage([inboxEvent()], 'cursor-one', true)
|
||||
: inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')], 'cursor-two');
|
||||
};
|
||||
$pull = new WarehousePull($this->inbox);
|
||||
expect($pull->run($fetch, 1, 1))->toMatchArray(['pages' => 1, 'inserted' => 1, 'has_more' => true]);
|
||||
expect($pull->run($fetch, 1, 1))->toMatchArray(['pages' => 1, 'inserted' => 1, 'has_more' => false])
|
||||
->and($seen)->toBe([[null, 1], ['cursor-one', 1]])
|
||||
->and($this->inbox->checkpoint()['last_event_id'])->toBe(2);
|
||||
});
|
||||
|
||||
test('transport failure preserves already accepted pages and later resumes the failed page', function () {
|
||||
$fetch = fn (?string $cursor, int $size) => $cursor === null ? inboxPage([inboxEvent()], 'cursor-one', true) : throw new RuntimeException('Indisponibilidade sintética.');
|
||||
$pull = new WarehousePull($this->inbox);
|
||||
expect(fn () => $pull->run($fetch, 5))->toThrow(RuntimeException::class)
|
||||
->and($this->inbox->checkpoint())->toBe(['version' => 1, 'cursor' => 'cursor-one', 'last_event_id' => 1]);
|
||||
$retry = function (?string $cursor, int $size): array {
|
||||
expect($cursor)->toBe('cursor-one');
|
||||
|
||||
return inboxPage([inboxEvent(2, 'receive', 'sgc:7:warehouse-event:1')]);
|
||||
};
|
||||
expect($pull->run($retry, 5)['inserted'])->toBe(1);
|
||||
});
|
||||
|
||||
test('pull stops at the actual end and rejects invalid bounds before calling a transport', function () {
|
||||
$calls = 0;
|
||||
$fetch = function (?string $cursor, int $size) use (&$calls): array {
|
||||
$calls++;
|
||||
|
||||
return inboxPage([]);
|
||||
};
|
||||
$pull = new WarehousePull($this->inbox);
|
||||
expect($pull->run($fetch, 100)['pages'])->toBe(1)->and($calls)->toBe(1);
|
||||
foreach ([[0, 1], [101, 1], [1, 0], [1, 101]] as [$pages, $size]) {
|
||||
expect(fn () => $pull->run($fetch, $pages, $size))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
expect($calls)->toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user