commit21
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Features;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
require_once __DIR__.'/../../Support/FortifyMfa.php';
|
||||
|
||||
test('two factor foundation remains disabled and secrets never enter user serialization', function () {
|
||||
expect(Features::enabled(Features::twoFactorAuthentication()))->toBeFalse();
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
expect($user->toArray())->not->toHaveKeys(['two_factor_secret', 'two_factor_recovery_codes']);
|
||||
expect($user->two_factor_secret)->not->toBe(decrypt($user->two_factor_secret));
|
||||
$this->get('/two-factor-challenge')->assertNotFound();
|
||||
});
|
||||
|
||||
test('two factor foundation challenges a confirmed account and accepts a recovery code only once', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$code = $user->recoveryCodes()[0];
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge');
|
||||
$this->assertGuest();
|
||||
$this->get('/two-factor-challenge')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
||||
->assertInertia(fn (Assert $p) => $p->component('auth/two-factor-challenge')->missing('secret')->missing('recoveryCodes'));
|
||||
$this->post('/two-factor-challenge', ['recovery_code' => $code])->assertRedirect();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->fresh()->recoveryCodes())->not->toContain($code);
|
||||
$this->post('/logout');
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge');
|
||||
$this->post('/two-factor-challenge', ['recovery_code' => $code])->assertSessionHasErrors();
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('two factor foundation rejects invalid codes and throttles repeated challenges', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$this->withSession(['login.id' => $user->id]);
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/two-factor-challenge', ['recovery_code' => 'invalid'])->assertUnprocessable();
|
||||
}
|
||||
$this->postJson('/two-factor-challenge', ['recovery_code' => 'invalid'])->assertStatus(429);
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('two factor foundation accepts an authenticator code without exposing its secret', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge');
|
||||
$code = (new Google2FA)->getCurrentOtp(decrypt($user->two_factor_secret));
|
||||
$this->post('/two-factor-challenge', ['code' => $code])->assertRedirect();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('two factor foundation does not challenge an unfinished enrollment', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
app(EnableTwoFactorAuthentication::class)($user);
|
||||
expect($user->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('two factor foundation password confirmation uses the native password check and private view', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/user/confirm-password')->assertOk()
|
||||
->assertInertia(fn (Assert $p) => $p->component('auth/confirm-password'));
|
||||
$this->post('/user/confirm-password', ['password' => 'wrong'])->assertSessionHasErrors('password');
|
||||
$this->post('/user/confirm-password', ['password' => 'password'])->assertSessionHasNoErrors();
|
||||
expect(session('auth.password_confirmed_at'))->not->toBeNull();
|
||||
});
|
||||
@@ -90,6 +90,47 @@ 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('supply task auto rule approval connects the reviewer to one requester draft while preserving human approvals', function () {
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$planning->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = $planning->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key,
|
||||
'safety_milli' => 120000, 'minimum_stock_milli' => 120000, 'target_milli' => 140000, 'maximum_milli' => 160000, 'daily_milli' => 0, 'minimum_milli' => 0, 'pack_milli' => 1000]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$policy = $planning->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$input = $this->input;
|
||||
$input['data']['policy_key'] = $policy->record_key;
|
||||
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $input);
|
||||
supplyGrant($this->f['issuer'], ['supply_review']);
|
||||
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false));
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', '/stock/reposicao?rule_id='.$rule->id.'#supply-rule-'.$rule->id));
|
||||
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('rules.total', 1)->where('rules.data.0.canApprove', true));
|
||||
$count = Requisition::query()->count();
|
||||
$approval = ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'data' => [], 'reason' => 'Conferência independente apenas no ensaio descartável.'];
|
||||
$this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors();
|
||||
$this->post('/stock/reposicao', $approval)->assertSessionHasNoErrors();
|
||||
$requisition = Requisition::query()->where('status', 'draft')->sole();
|
||||
expect(Requisition::query()->count())->toBe($count + 1)->and($requisition->requester_id)->toBe($this->f['issuer']->id)
|
||||
->and($requisition->items()->sole()->quantity)->toBe('20.000')->and($requisition->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/stock/reposicao?rule_id='.$rule->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('rules.data.0.state', 'approved')->where('rules.data.0.canApprove', false));
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'prepare')->where('tasks.data.0.href', '/requisicoes/'.$requisition->id));
|
||||
expect($requisition->fresh()->status)->toBe('draft');
|
||||
});
|
||||
|
||||
test('supply task auto rule preserves the existing ability of an authorised preparer to suspend an active rule', function () {
|
||||
$approved = activateAutomaticRule($this);
|
||||
supplyGrant($this->f['issuer'], ['supply_review']);
|
||||
$this->actingAs($this->f['issuer'])->get('/stock/reposicao?rule_id='.$approved->id)->assertInertia(fn (Assert $page) => $page->where('rules.data.0.canApprove', false)->where('rules.data.0.canSuspend', true));
|
||||
expect(app(AutomaticPurchases::class)->canReview($this->f['issuer'], $approved, 'suspend'))->toBeTrue();
|
||||
$this->post('/stock/reposicao', ['operation_key' => (string) Str::uuid(), 'record_key' => $approved->record_key, 'version' => $approved->version, 'action' => 'suspend', 'data' => [], 'reason' => 'Suspender a regra pelo percurso já existente.'])->assertSessionHasNoErrors();
|
||||
expect(app(SupplyPlanning::class)->latest('auto_rule')->sole()->state)->toBe('suspended');
|
||||
});
|
||||
|
||||
test('durable signals survive the missing immediate callback and scheduled recovery never impersonates or duplicates drafts', function () {
|
||||
$id = pilotWork($this);
|
||||
$before = Requisition::query()->count();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Department;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
@@ -26,6 +28,70 @@ beforeEach(function () {
|
||||
$this->actingAs($this->actor);
|
||||
});
|
||||
|
||||
test('requisition audit export includes every metadata record without private payloads or read side effects', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
for ($i = 0; $i < 105; $i++) {
|
||||
AuditLog::query()->create(['tenant_id' => $r->tenant_id, 'requisition_id' => $r->id, 'user_id' => $this->actor->id,
|
||||
'action' => $i === 0 ? "\t=FORMULA-TEST" : 'qa.metadata.'.$i, 'created_at' => now(),
|
||||
'before' => ['bank' => 'PRIVATE-BEFORE-MARKER'], 'after' => ['account_identifier' => 'PRIVATE-AFTER-MARKER', 'execution' => ['mode' => 'scheduled_replenishment']]]);
|
||||
}
|
||||
$expectedIds = AuditLog::query()->where('requisition_id', $r->id)->orderBy('id')->pluck('id')->all();
|
||||
$before = [];
|
||||
foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$response = $this->get('/requisicoes/'.$r->id.'/historico.csv')->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8')
|
||||
->assertHeader('Cache-Control', 'max-age=0, no-store, private');
|
||||
$csv = $response->streamedContent();
|
||||
expect($csv)->not->toContain('PRIVATE-BEFORE-MARKER')->not->toContain('PRIVATE-AFTER-MARKER')->toContain('scheduled_replenishment');
|
||||
$stream = fopen('php://temp', 'w+');
|
||||
fwrite($stream, $csv);
|
||||
rewind($stream);
|
||||
fgetcsv($stream, null, ';', '"', '');
|
||||
fgetcsv($stream, null, ';', '"', '');
|
||||
$ids = [];
|
||||
$actions = [];
|
||||
while (($row = fgetcsv($stream, null, ';', '"', '')) !== false) {
|
||||
$ids[] = (int) $row[0];
|
||||
$actions[] = $row[2];
|
||||
expect($row)->toHaveCount(5);
|
||||
}
|
||||
fclose($stream);
|
||||
expect($ids)->toBe($expectedIds)->and($actions[1])->toStartWith("'");
|
||||
$this->get('/requisicoes/'.$r->id)->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->has('audit', 25)->where('auditPagination.total', count($expectedIds))->missing('audit.1.after')->where('audit.1.execution', 'scheduled_replenishment'));
|
||||
$this->get('/requisicoes/'.$r->id.'?audit_page=2')->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->has('audit', 25)->where('audit.0.id', $expectedIds[25])->where('auditPagination.current_page', 2));
|
||||
$this->get('/requisicoes/'.$r->id.'?audit_page=5')->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->has('audit', 6)->where('audit.0.id', $expectedIds[100])->where('auditPagination.next_page_url', null));
|
||||
$this->getJson('/requisicoes/'.$r->id.'?audit_page=0')->assertUnprocessable();
|
||||
$this->getJson('/requisicoes/'.$r->id.'?audit_page=1000001')->assertUnprocessable();
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
});
|
||||
|
||||
test('requisition audit export preserves requester department company and current access boundaries', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$url = '/requisicoes/'.$r->id.'/historico.csv';
|
||||
$other = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($other)->get($url)->assertNotFound();
|
||||
$department = Department::query()->create(['tenant_id' => $r->tenant_id, 'name' => 'Outro departamento sintético', 'code' => 'OTHER-AUDIT']);
|
||||
$head = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get($url)->assertNotFound();
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['issuer'])->get($url)->assertNotFound();
|
||||
$this->actingAs($this->actor);
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
supplyGrant($this->actor, ['requester']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => true]);
|
||||
$this->get($url)->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->actor->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]);
|
||||
$this->get($url)->assertForbidden();
|
||||
});
|
||||
|
||||
test('autonomous adapter executes shared lifecycle with one request one reserve and independent versioned approval', function () {
|
||||
$count = Requisition::query()->count();
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
@@ -128,6 +194,57 @@ test('profile task catalogue covers the existing permissions without creating a
|
||||
->where('workflowTasks.department_head.0.key', 'requisition.approve.department_head'));
|
||||
});
|
||||
|
||||
test('notice consultation searches the full visible scope and reading preserves filters without deciding', function () {
|
||||
$requests = [];
|
||||
for ($i = 0; $i < 28; $i++) {
|
||||
$this->postJson('/requisicoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$requests[] = $r->fresh();
|
||||
}
|
||||
$old = $requests[0];
|
||||
$notice = DB::table('workflow_notices')->where('requisition_id', $old->id)->sole();
|
||||
$before = [];
|
||||
foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'workflow_notices', 'audit_logs'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$this->actingAs($this->head)->get('/requisicoes/avisos?view=tasks&search=req-&page=2')->assertOk()
|
||||
->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('notices.total', 28)->has('notices.data', 3)->where('unread', 28)
|
||||
->where('notices.prev_page_url', fn ($url) => str_contains($url, 'search=req-') && str_contains($url, 'view=tasks')));
|
||||
$path = '/requisicoes/avisos?view=unread&search='.urlencode(mb_strtolower($old->number)).'&page=999';
|
||||
$this->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 1)->where('notices.current_page', 1)
|
||||
->where('notices.data.0.id', $notice->id)->where('unread', 28));
|
||||
$this->get('/requisicoes/avisos?search='.urlencode("' OR 1=1 --"))->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('unread', 28));
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
$filters = ['view' => 'unread', 'page' => 2, 'search' => $old->number];
|
||||
$redirect = '/requisicoes/avisos?'.http_build_query($filters);
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect);
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertRedirect($redirect);
|
||||
$this->get($redirect)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0)->where('notices.current_page', 1)->where('unread', 27));
|
||||
foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements'] as $table) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($before[$table]);
|
||||
}
|
||||
expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(1);
|
||||
$this->actingAs($this->actor)->get($path)->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
|
||||
});
|
||||
|
||||
test('notice consultation rejects invalid context before recording a read', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$notice = DB::table('workflow_notices')->where('requisition_id', $r->id)->sole();
|
||||
$this->actingAs($this->head);
|
||||
foreach ([['search' => str_repeat('A', 101)], ['page' => 0], ['view' => 'invalid']] as $filters) {
|
||||
$this->getJson('/requisicoes/avisos?'.http_build_query($filters))->assertUnprocessable();
|
||||
$this->postJson('/requisicoes/avisos/'.$notice->id.'/ler', $filters)->assertUnprocessable();
|
||||
}
|
||||
expect(DB::table('workflow_notices')->where('id', $notice->id)->value('read_at'))->toBeNull();
|
||||
expect(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('internal notices are created once with submission and advance only after a valid decision', function () {
|
||||
$this->f['tenant']->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]);
|
||||
$buyer = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryCorrectionPage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
supplyGrant($this->f['receiver'], ['inventory_disposition_prepare', 'inventory_reversal_prepare']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
});
|
||||
|
||||
test('correction source access rechecks expired and maintenance profiles without writing', function (string $mode) {
|
||||
$service = app(InventoryCorrectionPage::class);
|
||||
$call = fn () => match ($mode) {
|
||||
'receipts' => $service->receipts(collect()),
|
||||
'operations' => $service->operations(collect()),
|
||||
default => $service->read(Request::create('/stock/estornos'), $this->f['receiver'], 'stock_reversal'),
|
||||
};
|
||||
$call();
|
||||
$before = DB::table('audit_logs')->count();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]);
|
||||
expect($call)->toThrow(HttpException::class);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]);
|
||||
expect($call)->toThrow(HttpException::class);
|
||||
expect(DB::table('audit_logs')->count())->toBe($before);
|
||||
})->with(['receipts', 'operations', 'records']);
|
||||
|
||||
test('correction read permission does not grant preparation source access', function () {
|
||||
$viewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
||||
supplyGrant($viewer, ['inventory_disposition_read', 'inventory_reversal_read']);
|
||||
$this->actingAs($viewer);
|
||||
$service = app(InventoryCorrectionPage::class);
|
||||
expect($service->read(Request::create('/stock/estornos'), $viewer, 'stock_reversal')['ready'])->toBeTrue();
|
||||
expect(fn () => $service->receipts(collect()))->toThrow(HttpException::class);
|
||||
expect(fn () => $service->operations(collect()))->toThrow(HttpException::class);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryCorrectionPage;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
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');
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_disposition_prepare', 'inventory_reversal_prepare']);
|
||||
supplyGrant($this->f['issuer'], ['inventory_disposition_review', 'inventory_reversal_review']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
$this->receiptItem = $this->receipt->items->first();
|
||||
});
|
||||
|
||||
function correctionPageDraft(object $test, string $kind): SupplyRecord
|
||||
{
|
||||
$workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class);
|
||||
$data = $kind === 'stock_event' ? ['kind' => 'supplier_return', 'stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'receipt_item_id' => $test->receiptItem->id, 'quantity' => '1.000'] : ['source_kind' => 'receipt', 'receipt_item_id' => $test->receiptItem->id];
|
||||
|
||||
return $workflow->save($test->f['receiver'], supplyInput(['reference' => 'FILTER-MATCH-ROOT', ...$data]));
|
||||
}
|
||||
|
||||
test('correction pagination reaches old pending processes without changing approvals stock or history', function (string $kind, string $path) {
|
||||
$first = correctionPageDraft($this, $kind);
|
||||
$beforeRecord = $first->fresh()->getAttributes();
|
||||
for ($i = 0; $i < 209; $i++) {
|
||||
$copy = $first->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->data = [...$copy->data, 'reference' => 'ARCHIVE-'.$i];
|
||||
$copy->created_at = $first->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('budget_entries')->count()];
|
||||
$this->actingAs($this->f['issuer'])->get($path)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 210)->has('records', 25));
|
||||
$this->get($path.'?records_page=9')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page
|
||||
->has('records', 10)->where('records.9.id', $first->id)->where('records.9.canApprove', true));
|
||||
$this->get($path.'?record_id='.$first->id.'&records_page=9')->assertInertia(fn (Assert $page) => $page->where('recordPagination', null)
|
||||
->has('records', 1)->where('records.0.id', $first->id)->has('history.'.$first->record_key, 1));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('budget_entries')->count()])->toBe($before)
|
||||
->and($first->fresh()->getAttributes())->toBe($beforeRecord);
|
||||
})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]);
|
||||
|
||||
test('correction pagination filters only current versions and direct links preserve current independent actions', function (string $kind, string $path) {
|
||||
$first = correctionPageDraft($this, $kind);
|
||||
$workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class);
|
||||
$data = [...$first->data, 'reference' => 'CURRENT-REFERENCE'];
|
||||
if ($kind === 'stock_event') {
|
||||
$data['quantity'] = '1.000';
|
||||
}
|
||||
$updated = $workflow->save($this->f['receiver'], supplyInput($data, 'save', $first));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $updated));
|
||||
$before = [$first->fresh()->getAttributes(), $approved->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()];
|
||||
$this->get($path.'?search=filter-match-root')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0));
|
||||
$this->get($path.'?search=current-reference&state=approved')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 1)
|
||||
->where('records.0.id', $approved->id)->has('history.'.$first->record_key, 3));
|
||||
$this->get($path.'?search=current-reference&state=draft')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get($path.'?record_id='.$first->id.'&search=NO-MATCH&state=executed')->assertInertia(fn (Assert $page) => $page
|
||||
->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', true)->where('recordPagination', null));
|
||||
expect([$first->fresh()->getAttributes(), $approved->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($before);
|
||||
})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]);
|
||||
|
||||
test('correction pagination isolates companies and validates pages states reference and focus type', function (string $kind, string $path) {
|
||||
$first = correctionPageDraft($this, $kind);
|
||||
foreach (['records_page=0', 'records_page=1000001', 'state=unknown', 'search='.str_repeat('A', 101)] as $query) {
|
||||
$this->getJson($path.'?'.$query)->assertUnprocessable();
|
||||
}
|
||||
$wrongPath = $kind === 'stock_event' ? '/stock/estornos' : '/stock/ocorrencias';
|
||||
$this->getJson($wrongPath.'?record_id='.$first->id)->assertUnprocessable();
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa isolada de correcções']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['inventory_disposition_read', 'inventory_reversal_read']);
|
||||
$this->actingAs($foreign)->get($path)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0));
|
||||
$this->getJson($path.'?record_id='.$first->id)->assertUnprocessable();
|
||||
})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]);
|
||||
|
||||
test('correction pagination preserves maintenance and membership expiry boundaries', function (string $kind, string $path) {
|
||||
correctionPageDraft($this, $kind);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->get($path)->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get($path)->assertForbidden();
|
||||
})->with([['stock_event', '/stock/ocorrencias'], ['stock_reversal', '/stock/estornos']]);
|
||||
|
||||
test('correction pagination preserves an older selected receipt and excludes it after a native reversal', function () {
|
||||
$draft = correctionPageDraft($this, 'stock_event');
|
||||
for ($i = 0; $i < 200; $i++) {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '0.001'));
|
||||
}
|
||||
$before = [$draft->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
expect(app(InventoryCorrectionPage::class)->receipts(collect(), mb_strtolower($this->receipt->number))->sole()->id)->toBe($this->receiptItem->id);
|
||||
$this->get('/stock/ocorrencias?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 201)
|
||||
->where('receipts', fn ($rows) => collect($rows)->where('id', $this->receiptItem->id)->count() === 1));
|
||||
expect([$draft->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$workflow = app(InventoryReversals::class);
|
||||
$correction = $workflow->save($this->f['receiver'], supplyInput(['reference' => 'CORRECT-OLD-RECEIPT', 'source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $correction));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$workflow->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção fictícia para validar a origem antiga.'], 'execute', $approved));
|
||||
$this->get('/stock/ocorrencias?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 200)
|
||||
->where('receipts', fn ($rows) => collect($rows)->where('id', $this->receiptItem->id)->isEmpty()));
|
||||
$this->get('/stock/ocorrencias?source_search='.urlencode($this->receipt->number).'&selected_receipt_id='.$this->receiptItem->id)->assertInertia(fn (Assert $page) => $page->has('receipts', 0));
|
||||
expect($draft->fresh()->getAttributes())->toBe($before[0])->and($this->receiptItem->fresh()->accepted_milli)->toBe(10000);
|
||||
});
|
||||
|
||||
test('correction pagination preserves an older selected operation without reintroducing an executed correction', function () {
|
||||
$issue = function (string $quantity) {
|
||||
return app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'OLD-ISSUE-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id,
|
||||
'quantity' => $quantity, 'reason' => 'Saída fictícia para testar a escolha de origens antigas.']);
|
||||
};
|
||||
$old = $issue('1.000');
|
||||
$workflow = app(InventoryReversals::class);
|
||||
$draft = $workflow->save($this->f['receiver'], supplyInput(['reference' => 'CORRECT-OLD-ISSUE', 'source_kind' => 'operation', 'source_operation_id' => $old->id]));
|
||||
for ($i = 0; $i < 200; $i++) {
|
||||
$issue('0.001');
|
||||
}
|
||||
$before = [$draft->fresh()->getAttributes(), $old->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
expect(app(InventoryCorrectionPage::class)->operations(collect(), mb_strtolower($old->reference))->sole()->id)->toBe($old->id);
|
||||
$this->get('/stock/estornos?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('operations', 201)
|
||||
->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->count() === 1));
|
||||
expect([$draft->fresh()->getAttributes(), $old->fresh()->getAttributes(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$updated = $workflow->save($this->f['receiver'], supplyInput($draft->data, 'save', $draft));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = $workflow->save($this->f['issuer'], supplyInput([], 'approve', $updated));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$executed = $workflow->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção fictícia conferida sobre a saída antiga.'], 'execute', $approved));
|
||||
$this->get('/stock/estornos?record_id='.$executed->id)->assertInertia(fn (Assert $page) => $page->has('operations', 200)
|
||||
->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->isEmpty()));
|
||||
$this->get('/stock/estornos?source_search='.urlencode($old->reference).'&selected_operation_id='.$old->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0));
|
||||
expect($old->fresh()->getAttributes())->toBe($before[1]);
|
||||
});
|
||||
|
||||
test('correction source search preserves eligible selections and validates company and access', function () {
|
||||
$page = app(InventoryCorrectionPage::class);
|
||||
expect($page->receipts(collect(), 'NO-MATCH-FICTICIO'))->toHaveCount(0);
|
||||
$this->get('/stock/ocorrencias?source_search=NO-MATCH-FICTICIO&selected_receipt_id='.$this->receiptItem->id)->assertInertia(fn (Assert $p) => $p->has('receipts', 1)->where('receipts.0.id', $this->receiptItem->id));
|
||||
$this->getJson('/stock/estornos?source_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$this->get('/stock/estornos?source_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('receipts', 0)->has('operations', 0));
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa de pesquisa isolada']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['inventory_disposition_prepare', 'inventory_reversal_prepare']);
|
||||
$this->actingAs($foreign)->getJson('/stock/ocorrencias?selected_receipt_id='.$this->receiptItem->id)->assertUnprocessable();
|
||||
supplyGrant($this->f['otherReceiver'], ['inventory_disposition_read', 'inventory_reversal_read']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/stock/estornos?source_search=TEST')->assertInertia(fn (Assert $p) => $p->where('capabilities.prepare', false)->has('receipts', 0)->has('operations', 0));
|
||||
});
|
||||
@@ -13,8 +13,10 @@ use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
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';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
@@ -189,3 +191,83 @@ test('HTTP menu and commands use the same functional permissions without automat
|
||||
$this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertForbidden();
|
||||
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('executed');
|
||||
});
|
||||
test('disposition task connects independent review and physical execution once without financial documents', function () {
|
||||
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
$path = '/stock/ocorrencias?record_id='.$draft->id;
|
||||
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
||||
supplyGrant($this->f['issuer'], ['warehouse']);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false));
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'disposition_approve')->where('tasks.data.0.href', $path.'#disposition-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()];
|
||||
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('focusChanged', false)->where('records.0.canApprove', true));
|
||||
expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), Invoice::query()->count()])->toBe($before);
|
||||
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', false)->where('records.0.canReturn', true));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_execute'));
|
||||
$command = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Saída física confirmada no ensaio descartável.'], 'execute', $approved);
|
||||
$this->postJson('/stock/ocorrencias', $command)->assertRedirect();
|
||||
$this->postJson('/stock/ocorrencias', $command)->assertRedirect();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(7000)
|
||||
->and(Invoice::query()->count())->toBe($before[3])->and(StockMovement::query()->count())->toBe($before[1] + 1);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('disposition task keeps changed stock sources pending for return without permitting silent execution', function () {
|
||||
$approved = approvedDisposition($this, ['kind' => 'write_off']);
|
||||
$this->f['stock']->update(['name' => 'Identificação revista depois da autorização']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.blocked', true));
|
||||
$this->postJson('/stock/ocorrencias', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'A saída não pode ignorar alteração da origem.'], 'execute', $approved))->assertUnprocessable();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'disposition_return')->where('tasks.data.0.blocked', true));
|
||||
$returned = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'return', $approved));
|
||||
expect($returned->state)->toBe('returned')->and($returned->snapshot['source'])->toBe($approved->snapshot['source']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('disposition task excludes every preparer while retaining the separately authorised physical executor', function () {
|
||||
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
supplyGrant($this->f['issuer'], ['inventory_disposition_prepare']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$updated = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput(dispositionData($this), 'save', $draft));
|
||||
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
||||
foreach ([$this->f['issuer'], $this->f['receiver']] as $person) {
|
||||
expect(app(InventoryDispositions::class)->canAct($person, $updated, 'approve'))->toBeFalse();
|
||||
}
|
||||
supplyGrant($this->f['otherReceiver'], ['inventory_disposition_review']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$approved = app(InventoryDispositions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated));
|
||||
expect(app(InventoryDispositions::class)->canAct($this->f['otherReceiver'], $approved, 'execute'))->toBeFalse()
|
||||
->and(app(InventoryDispositions::class)->canAct($this->f['receiver'], $approved, 'execute'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('disposition task preserves oldest pending events beyond the initial display cap', function () {
|
||||
$first = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
}
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)
|
||||
->where('tasks.data.0.id', 'disposition:'.$first->id.':approve'));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get('/stock/ocorrencias')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105));
|
||||
$this->get('/stock/ocorrencias?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)->where('records.0.canApprove', true));
|
||||
});
|
||||
|
||||
test('disposition task preserves company isolation maintenance and expired assignments', function () {
|
||||
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
$path = '/stock/ocorrencias?record_id='.$draft->id;
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['warehouse']);
|
||||
$this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertForbidden();
|
||||
expect(app(InventoryDispositions::class)->canAct($this->f['issuer'], $draft, 'approve'))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryLotAllocation;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
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';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
@@ -41,6 +45,13 @@ function lotContext($test): array
|
||||
return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id);
|
||||
}
|
||||
|
||||
function lotQualityTasks(object $test, User $actor): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'lot-quality:')));
|
||||
}
|
||||
|
||||
function lotDecision($test, array $lot, string $action = 'release', $actor = null, ?int $warehouse = null): SupplyRecord
|
||||
{
|
||||
$actor ??= $test->f['receiver'];
|
||||
@@ -332,3 +343,270 @@ test('later identification corrections preserve the metadata actually used in th
|
||||
->and(lotContext($this)['usable_milli'])->toBe(0)
|
||||
->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0);
|
||||
});
|
||||
function taskLotDraft($test, array $extra = [], ?SupplyRecord $previous = null): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [], ...$extra], 'prepare', $previous));
|
||||
}
|
||||
|
||||
test('lot setup task connects independent activation without changing physical stock or releasing quality', function () {
|
||||
$draft = taskLotDraft($this, ['lots' => [['code' => 'TASK-LOT', 'expires_on' => today()->addDays(20)->toDateString()]]]);
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_review']);
|
||||
$path = '/stock/lotes?record_id='.$draft->id;
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'lot_setup_activate')->where('tasks.data.0.href', $path.'#lot-setup-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()];
|
||||
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canActivate', true));
|
||||
expect([SupplyRecord::query()->count(), StockMovement::query()->count(), InventoryLotAllocation::query()->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$command = supplyInput([], 'activate', $draft);
|
||||
$this->postJson('/stock/lotes', $command)->assertRedirect();
|
||||
$this->postJson('/stock/lotes', $command)->assertRedirect();
|
||||
$context = lotContext($this);
|
||||
expect($context['physical']['quantity'])->toBe(10000)->and($context['usable_milli'])->toBe(0)->and($context['lots'][0]['state'])->toBe('held')
|
||||
->and(StockMovement::query()->count())->toBe($before[1])->and(InventoryLotAllocation::query()->count())->toBe(1);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'lot_release'));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.state', 'active')->where('records.0.canActivate', false));
|
||||
});
|
||||
|
||||
test('lot setup task rejects a stale balance and reuses the revised preparation at the old link', function () {
|
||||
$draft = taskLotDraft($this);
|
||||
lotIssue($this, '1.000');
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertUnprocessable();
|
||||
expect(InventoryLotAllocation::query()->count())->toBe(0);
|
||||
$updated = taskLotDraft($this, [], $draft);
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $updated->id));
|
||||
$this->postJson('/stock/lotes', supplyInput([], 'activate', $updated))->assertRedirect();
|
||||
expect(lotContext($this)['physical']['quantity'])->toBe(9000);
|
||||
});
|
||||
|
||||
test('lot setup task preserves active configuration while an independently prepared revision awaits review', function () {
|
||||
$active = lotSetup($this, [['code' => 'BASE-TASK', 'expires_on' => today()->addDays(20)->toDateString()]]);
|
||||
$counts = [StockMovement::query()->count(), InventoryLotAllocation::query()->count()];
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
$draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active));
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['inventory_lot_review']);
|
||||
expect(app(InventoryLots::class)->canActivate($this->f['issuer'], $draft))->toBeFalse()
|
||||
->and(app(InventoryLots::class)->canActivate($this->f['otherReceiver'], $draft))->toBeFalse();
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/lotes?record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->has('records', 2)
|
||||
->where('records.0.id', $draft->id)->where('records.1.id', $active->id)->where('contexts.'.$active->record_key.'.setup_id', $active->id));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 2)
|
||||
->where('tasks.data', fn ($rows) => collect($rows)->pluck('task')->sort()->values()->all() === ['lot_release', 'lot_setup_activate']));
|
||||
$this->postJson('/stock/lotes', supplyInput([], 'activate', $draft))->assertRedirect();
|
||||
expect([StockMovement::query()->count(), InventoryLotAllocation::query()->count()])->toBe($counts)
|
||||
->and($active->fresh()->data['minimum_remaining_days'])->toBe(0)->and(app(InventoryLots::class)->setup($active->stock_item_id, $active->warehouse_id)->data['minimum_remaining_days'])->toBe(5);
|
||||
});
|
||||
|
||||
test('lot setup task retains older pending setups past the overview limit', function () {
|
||||
$first = taskLotDraft($this);
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
taskLotDraft($this);
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)
|
||||
->where('tasks.data.0.id', 'lot-setup:'.$first->id.':activate'));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('controlPagination.total', 105));
|
||||
$this->get('/stock/lotes?controls_page=5')->assertInertia(fn (Assert $page) => $page->has('records', 5)->where('records.4.id', $first->id));
|
||||
$this->get('/stock/lotes?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id));
|
||||
});
|
||||
|
||||
test('lot setup task preserves company maintenance and expiry boundaries', function () {
|
||||
$draft = taskLotDraft($this);
|
||||
$path = '/stock/lotes?record_id='.$draft->id;
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['inventory_lot_review']);
|
||||
$this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertForbidden();
|
||||
expect(app(InventoryLots::class)->canActivate($this->f['receiver'], $draft))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden();
|
||||
});
|
||||
test('lot setup task does not keep an obsolete competing preparation actionable after another control is activated', function () {
|
||||
$older = taskLotDraft($this);
|
||||
$winner = taskLotDraft($this);
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/lotes', supplyInput([], 'activate', $winner))->assertRedirect();
|
||||
$this->postJson('/stock/lotes', supplyInput([], 'activate', $older))->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/stock/lotes?record_id='.$older->id)->assertInertia(fn (Assert $page) => $page->where('records.0.canActivate', false));
|
||||
expect($older->fresh()->state)->toBe('draft')->and(app(InventoryLots::class)->setup($older->stock_item_id, $older->warehouse_id)->record_key)->toBe($winner->record_key);
|
||||
});
|
||||
|
||||
test('lot quality task connects identification and independent release without changing physical stock', function () {
|
||||
lotSetup($this);
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
$tasks = lotQualityTasks($this, $this->f['issuer']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_identify')->and($tasks[0]['due_on'])->toBeNull();
|
||||
$this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('focusLotId', $lot['id'])->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canIdentify', true));
|
||||
$metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
|
||||
$before = $metadata->getAttributes();
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'IDENTIFIED-QA', 'expires_on' => today()->addDays(10)->toDateString()], 'identify', $metadata));
|
||||
expect(lotQualityTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$tasks = lotQualityTasks($this, $this->f['receiver']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release');
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['usable_milli'])->toBe(10000)
|
||||
->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($metadata->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('lot quality task excludes receipt contributors even when release permission is accumulated', function () {
|
||||
lotSetup($this, [['code' => 'BASE', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$data = receiptPayload($this->f, '2.000');
|
||||
$data['items'][0]['lots'] = [['code' => 'RECEIVED-QA', 'expires_on' => today()->addDays(10)->toDateString()]];
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data);
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]);
|
||||
$tasks = lotQualityTasks($this, $this->f['otherReceiver']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release');
|
||||
$lot = collect(lotContext($this)['lots'])->firstWhere('data.code', 'RECEIVED-QA');
|
||||
lotDecision($this, $lot, 'release', $this->f['otherReceiver']);
|
||||
expect(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]);
|
||||
});
|
||||
|
||||
test('lot quality task detects expiry without movement and requests explicit retention only for prior release', function () {
|
||||
lotSetup($this, [['code' => 'EXPIRY-QA', 'expires_on' => today()->addDay()->toDateString()]]);
|
||||
$release = lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$before = SupplyRecord::query()->count();
|
||||
$this->travel(2)->days();
|
||||
$tasks = lotQualityTasks($this, $this->f['receiver']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_hold')
|
||||
->and(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['usable_milli'])->toBe(0)
|
||||
->and(lotContext($this)['physical']['quantity'])->toBe(10000)->and($release->fresh()->state)->toBe('released');
|
||||
$context = lotContext($this);
|
||||
$this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.canHold', true));
|
||||
lotDecision($this, $context['lots'][0], 'hold');
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and($release->fresh()->state)->toBe('released');
|
||||
});
|
||||
|
||||
test('lot quality task reappears after metadata correction and stale metadata cannot decide', function () {
|
||||
lotSetup($this, [['code' => 'BEFORE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
$entry = lotContext($this)['lots'][0];
|
||||
$release = lotDecision($this, $entry);
|
||||
$metadata = SupplyRecord::query()->findOrFail($entry['metadata_id']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'AFTER-QA', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', $metadata));
|
||||
$tasks = lotQualityTasks($this, $this->f['receiver']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('lot_release')
|
||||
->and(app(InventoryLots::class)->canIdentify($this->f['issuer'], $metadata))->toBeFalse()
|
||||
->and(app(InventoryLots::class)->canDecideLot($this->f['receiver'], $metadata, lotContext($this), $entry, 'release'))->toBeFalse()
|
||||
->and($release->fresh()->data['metadata_id'])->toBe($metadata->id);
|
||||
$this->get($tasks[0]['href'])->assertInertia(fn (Assert $page) => $page->where('contexts.'.app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->record_key.'.lots.0.data.code', 'AFTER-QA'));
|
||||
});
|
||||
|
||||
test('lot quality task enforces company warehouse maintenance and membership expiry', function () {
|
||||
$setup = lotSetup($this, [['code' => 'SCOPE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
$entry = lotContext($this)['lots'][0];
|
||||
$path = '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id;
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['inventory_lot_release']);
|
||||
expect(lotQualityTasks($this, $foreign['receiver']))->toBe([]);
|
||||
$this->getJson($path)->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->getJson('/stock/lotes?lot_id='.$entry['id'])->assertUnprocessable();
|
||||
$this->getJson($path.'&record_id='.$setup->id)->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]);
|
||||
$this->postJson('/stock/lotes', supplyInput(['lot_id' => $entry['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $entry['state_version']], 'release', SupplyRecord::query()->findOrFail($entry['metadata_id'])))->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([]);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(0);
|
||||
});
|
||||
|
||||
test('lot quality task does not offer release against an unreconciled partition', function () {
|
||||
$setup = lotSetup($this, [['code' => 'BALANCE-QA', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
$context = lotContext($this);
|
||||
$entry = $context['lots'][0];
|
||||
$context['reconciled'] = false;
|
||||
$this->partialMock(InventoryLots::class)->shouldReceive('context')->andReturn($context);
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['reconciled'])->toBeFalse();
|
||||
$this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('contexts.'.$setup->record_key.'.lots.0.canRelease', false));
|
||||
});
|
||||
|
||||
test('lot quality task retains over one hundred lots with paginated tasks and exact links', function () {
|
||||
$entries = array_map(fn ($i) => ['code' => 'VOLUME-'.$i, 'quantity' => '0.100', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 100));
|
||||
lotSetup($this, $entries);
|
||||
$data = receiptPayload($this->f, '5.000');
|
||||
$data['items'][0]['lots'] = array_map(fn ($i) => ['code' => 'NEW-'.$i, 'quantity' => '1.000', 'expires_on' => today()->addDays(10)->toDateString()], range(1, 5));
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data);
|
||||
$tasks = lotQualityTasks($this, $this->f['otherReceiver']);
|
||||
expect($tasks)->toHaveCount(105);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->has('tasks.data', 5));
|
||||
$before = SupplyRecord::query()->count();
|
||||
$this->get($tasks[104]['href'])->assertOk()->assertInertia(fn (Assert $page) => $page->where('focusLotId', lotContext($this)['lots'][104]['id']));
|
||||
expect(SupplyRecord::query()->count())->toBe($before)->and(lotContext($this)['physical']['quantity'])->toBe(15000);
|
||||
});
|
||||
|
||||
test('lot quality task preserves the approved receipt quarantine policy and omits empty lots', function () {
|
||||
lotSetup($this, [['code' => 'POLICY-BASE', 'expires_on' => today()->addDays(10)->toDateString()]], ['quarantine_on_receipt' => false]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$data = receiptPayload($this->f, '2.000');
|
||||
$data['items'][0]['lots'] = [['code' => 'POLICY-NEW', 'expires_on' => today()->addDays(10)->toDateString()]];
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data);
|
||||
$entry = collect(lotContext($this)['lots'])->firstWhere('data.code', 'POLICY-NEW');
|
||||
expect($entry['quality_released'])->toBeTrue()->and($entry['release_mode'])->toBe('receipt_policy')
|
||||
->and(lotQualityTasks($this, $this->f['otherReceiver']))->toBe([]);
|
||||
lotIssue($this, '12.000');
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'EMPTY-CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($entry['metadata_id'])));
|
||||
expect(lotQualityTasks($this, $this->f['receiver']))->toBe([])->and(lotContext($this)['physical']['quantity'])->toBe(0);
|
||||
});
|
||||
|
||||
test('lot pagination reaches old controls and history beyond the former fixed limits without writes', function () {
|
||||
$first = taskLotDraft($this);
|
||||
for ($i = 0; $i < 209; $i++) {
|
||||
$copy = $first->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->created_at = $first->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()];
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 210)
|
||||
->where('historyPagination.total', 210)->has('records', 25)->has('history', 50));
|
||||
$this->get('/stock/lotes?controls_page=9&history_page=5')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page
|
||||
->where('controlPagination.current_page', 9)->where('historyPagination.current_page', 5)->has('records', 10)->has('history', 10)
|
||||
->where('records.9.id', $first->id)->where('history.9.id', $first->id)
|
||||
->where('controlPagination.prev_page_url', fn ($url) => str_contains($url, 'history_page=5'))
|
||||
->where('historyPagination.prev_page_url', fn ($url) => str_contains($url, 'controls_page=9')));
|
||||
$this->get('/stock/lotes?record_id='.$first->id.'&history_page=5')->assertInertia(fn (Assert $page) => $page->where('controlPagination', null)
|
||||
->has('records', 1)->where('records.0.id', $first->id)->where('historyPagination.total', 210));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), StockMovement::query()->count()])->toBe($before);
|
||||
});
|
||||
|
||||
test('lot pagination keeps active control and pending revision together at the page boundary and exact lot focus', function () {
|
||||
$active = lotSetup($this, [['code' => 'PAGE-BASE', 'expires_on' => today()->addDays(20)->toDateString()]]);
|
||||
$entry = lotContext($this)['lots'][0];
|
||||
$before = $active->fresh()->getAttributes();
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
$draft = app(InventoryLots::class)->save($this->f['otherReceiver'], supplyInput([...$active->data, 'minimum_remaining_days' => 5, 'lots' => []], 'prepare', $active));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
for ($i = 0; $i < 25; $i++) {
|
||||
$item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'PAGE-'.$i, 'name' => 'Artigo sintético sem saldo '.$i, 'unit' => 'cx']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $item->id, 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'));
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/lotes?controls_page=2')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 26)
|
||||
->has('records', 2)->where('records.0.id', $draft->id)->where('records.1.id', $active->id)
|
||||
->where('contexts.'.$active->record_key.'.setup_id', $active->id));
|
||||
$this->get('/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$this->f['warehouse']->id)->assertInertia(fn (Assert $page) => $page->where('controlPagination', null)
|
||||
->has('records', 2)->where('focusLotId', $entry['id'])->where('contexts.'.$active->record_key.'.setup_id', $active->id));
|
||||
expect($active->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('lot pagination rejects invalid pages and preserves tenant history boundaries', function () {
|
||||
taskLotDraft($this);
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['inventory_lot_read']);
|
||||
$this->actingAs($foreign['receiver'])->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->where('controlPagination.total', 0)->where('historyPagination.total', 0));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
foreach (['controls_page=0', 'history_page=-1', 'controls_page=abc', 'history_page=1000001'] as $query) {
|
||||
$this->getJson('/stock/lotes?'.$query)->assertUnprocessable();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,8 +16,11 @@ use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
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';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
@@ -91,12 +94,17 @@ test('full issue reversal preserves the original and is exactly once even on an
|
||||
expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () {
|
||||
test('coordination task returns when a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () {
|
||||
$reservation = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'quantity_milli' => 3000, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => 'NEED-REV']));
|
||||
$issue = reversalStock($this);
|
||||
$fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 3000], 'fulfil', $reservation));
|
||||
$pending = fn () => collect(app(WorkflowTaskInbox::class)->build($this->f['receiver'], 'procurement'))->where('task', 'reservation_followup')->count();
|
||||
expect($pending())->toBe(0);
|
||||
reverseSourceOperation($this, $issue);
|
||||
expect($pending())->toBe(1)->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $fulfilled, 'fulfil'))->toBeTrue();
|
||||
$this->get('/compras/fornecimento?view=reservations&issue_record_id='.$fulfilled->id.'&selected_issue_id='.$issue->id)
|
||||
->assertInertia(fn (Assert $page) => $page->has('operations', 0));
|
||||
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
|
||||
expect($events['reservations'][0]['quantity'])->toBe(3000)->and($events['fulfilled_needs'])->toBe([])->and($events['consumed_today'])->toBe(0)
|
||||
->and($fulfilled->fresh()->state)->toBe('fulfilled')->and($fulfilled->data['applications'])->toHaveCount(1);
|
||||
@@ -106,6 +114,7 @@ test('a corrected issue no longer satisfies a reservation and the same need can
|
||||
$again = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $nextIssue->id, 'quantity_milli' => 3000], 'fulfil', $fulfilled));
|
||||
expect(app(SupplyCoordination::class)->remainingReservation($again))->toBe(0)->and($again->data['applications'])->toHaveCount(2);
|
||||
expect($again->snapshot['effective_allocated_milli'])->toBe(3000)->and($again->snapshot['reversed_operations'])->toContain($issue->id);
|
||||
expect($pending())->toBe(0);
|
||||
});
|
||||
|
||||
test('a reversal outside a closed history period invalidates its signature and corrects effective consumption', function () {
|
||||
@@ -285,3 +294,74 @@ test('voided receipt matching blocks new invoices while preserving invoice facts
|
||||
$this->get('/facturas')->assertOk();
|
||||
$this->actingAs($this->f['receiver'])->get('/recepcoes/'.$this->receipt->id)->assertOk();
|
||||
});
|
||||
test('reversal task links independent approval and exactly one physical correction while preserving originals', function (string $kind) {
|
||||
$operation = $kind === 'operation' ? reversalStock($this) : null;
|
||||
$source = $kind === 'operation' ? ['source_kind' => 'operation', 'source_operation_id' => $operation->id] : ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id];
|
||||
$original = $operation?->fresh()->getAttributes() ?? $this->receiptItem->fresh()->getAttributes();
|
||||
$draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'TASK-'.Str::uuid(), ...$source]));
|
||||
$path = '/stock/estornos?record_id='.$draft->id;
|
||||
supplyGrant($this->f['receiver'], ['inventory_reversal_review']);
|
||||
supplyGrant($this->f['issuer'], ['warehouse']);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'reversal_approve')->where('tasks.data.0.href', $path.'#reversal-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), DB::table('invoices')->count()];
|
||||
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true));
|
||||
expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count(), DB::table('invoices')->count()])->toBe($before);
|
||||
$approved = app(InventoryReversals::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canExecute', false));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'reversal_execute'));
|
||||
$command = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Correcção integral confirmada no ensaio descartável.'], 'execute', $approved);
|
||||
$this->postJson('/stock/estornos', $command)->assertRedirect();
|
||||
$this->postJson('/stock/estornos', $command)->assertRedirect();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe($kind === 'operation' ? 10000 : 0)
|
||||
->and(StockMovement::query()->count())->toBe($before[1] + 1)->and(DB::table('invoices')->count())->toBe($before[3])
|
||||
->and($operation?->fresh()->getAttributes() ?? $this->receiptItem->fresh()->getAttributes())->toBe($original);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
})->with(['operation', 'receipt']);
|
||||
|
||||
test('reversal task does not withdraw unavailable receipt stock and still permits an authorised return', function () {
|
||||
$approved = approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]);
|
||||
reversalStock($this, '1.000');
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.blocked', true));
|
||||
$this->postJson('/stock/estornos', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'O consumo posterior deve impedir uma retirada sem saldo.'], 'execute', $approved))->assertUnprocessable();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(9000);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'reversal_return')->where('tasks.data.0.blocked', true));
|
||||
$returned = app(InventoryReversals::class)->save($this->f['issuer'], supplyInput([], 'return', $approved));
|
||||
expect($returned->state)->toBe('returned')->and($returned->snapshot['source'])->toBe($approved->snapshot['source']);
|
||||
});
|
||||
|
||||
test('reversal task preserves older pending sources beyond the hundred row overview', function () {
|
||||
$operation = reversalStock($this);
|
||||
$first = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'OLD-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id]));
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'PENDING-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id]));
|
||||
}
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)
|
||||
->where('tasks.data.0.id', 'reversal:'.$first->id.':approve'));
|
||||
$this->get('/requisicoes/tarefas?module=warehouse&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get('/stock/estornos')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105));
|
||||
$this->get('/stock/estornos?record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id));
|
||||
});
|
||||
|
||||
test('reversal task preserves record kind company maintenance and assignment expiry boundaries', function () {
|
||||
$operation = reversalStock($this);
|
||||
$draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'SCOPE-'.Str::uuid(), 'source_kind' => 'operation', 'source_operation_id' => $operation->id]));
|
||||
$path = '/stock/estornos?record_id='.$draft->id;
|
||||
$other = receiptFixture();
|
||||
supplyGrant($other['receiver'], ['warehouse']);
|
||||
$this->actingAs($other['receiver'])->getJson($path)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$disposition = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['kind' => 'write_off', 'reference' => 'OTHER-'.Str::uuid(),
|
||||
'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'quantity' => '1.000']));
|
||||
$this->getJson('/stock/estornos?record_id='.$disposition->id)->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=warehouse')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertForbidden();
|
||||
expect(app(InventoryReversals::class)->canAct($this->f['issuer'], $draft, 'approve'))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=warehouse')->assertForbidden();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\{Invoice, Tenant, User};
|
||||
use App\Services\{ExternalPaymentWorkflow, SupplierPayables};
|
||||
use Illuminate\Support\Facades\{Artisan, DB};
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once __DIR__.'/../Support/PaymentFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
final class NativePayablePageTest extends TestCase
|
||||
{
|
||||
private array $f;private User $reader;private string $instance;
|
||||
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]);
|
||||
$this->f=\paymentFixture();$this->instance='native-page-'.$this->f['tenant']->id;config(['common-payables.enabled'=>true,'common-payables.history_enabled'=>true,'common-payables.paged_read_enabled'=>true,'common-payables.source_instance'=>$this->instance]);
|
||||
$this->reader=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'purchasing_role'=>PurchasingRole::Finance]);
|
||||
foreach([[$this->f['author'],['view','prepare','validate']],[$this->f['validator'],['view','prepare','validate']],[$this->reader,['view']]] as [$u,$actions])foreach($actions as $a)DB::table('pa_access')->insert(['tenant_id'=>$u->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$u->id,'person'=>'native-page-person-'.$u->id,'action'=>$a]);
|
||||
foreach([['30.00','confirmed'],['10.00','pending'],['5.00','rejected']] as [$amount,$status]){$this->actingAs($this->f['author']);$p=app(ExternalPaymentWorkflow::class)->register($this->f['author'],$this->f['invoice'],[...$this->f['payment_data'],'amount'=>$amount,'external_reference'=>'NATIVE-PAGE-'.$status]);if($status!=='pending'){$this->actingAs($this->f['validator']);app(ExternalPaymentWorkflow::class)->review($this->f['validator'],$p,$status,'Synthetic independent original payment review.');}}
|
||||
$this->actingAs($this->f['author']);$p=app(SupplierPayables::class)->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic original history proposal.',(string)Str::uuid());$this->actingAs($this->f['validator']);app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history',$p['proposal_id'],'Synthetic distinct original history review.',(string)Str::uuid());$this->actingAs($this->reader);
|
||||
}
|
||||
private function service():SupplierPayables{return app(SupplierPayables::class);}
|
||||
private function clones(int $count):void
|
||||
{
|
||||
$row=DB::table('invoices')->where('id',$this->f['invoice']->id)->first();$line=(array)DB::table('invoice_items')->where('invoice_id',$row->id)->first();unset($line['id']);$audit=(array)DB::table('audit_logs')->where('tenant_id',$row->tenant_id)->where('action','invoice.validated')->where('after->invoice_id',$row->id)->first();unset($audit['id']);
|
||||
for($n=1;$n<=$count;$n++){$copy=(array)$row;unset($copy['id']);$copy['number']='PAGE-'.str_pad((string)$n,5,'0',STR_PAD_LEFT);$snapshot=json_decode($copy['snapshot'],true);$snapshot['supplier']['name']=$n%2===0?'Page Alpha':'Page Beta';$copy['snapshot']=json_encode($snapshot,JSON_THROW_ON_ERROR);$id=DB::table('invoices')->insertGetId($copy);DB::table('invoice_items')->insert(array_replace($line,['invoice_id'=>$id]));$after=json_decode($audit['after'],true);$after['invoice_id']=$id;DB::table('audit_logs')->insert(array_replace($audit,['after'=>json_encode($after,JSON_THROW_ON_ERROR)]));}
|
||||
}
|
||||
private function positions(array $data):string{$rows=$data['obligations'];usort($rows,fn($a,$b)=>$a['id']<=>$b['id']);return Payables::canonical($rows);}
|
||||
private function duplicateRegistration():void
|
||||
{
|
||||
$p=DB::table('external_payments')->where('tenant_id',$this->f['tenant']->id)->where('invoice_id',$this->f['invoice']->id)->where('status','confirmed')->first();$row=(array)DB::table('audit_logs')->where('tenant_id',$p->tenant_id)->where('action','payment.registered')->where('after->payment_id',$p->id)->first();unset($row['id']);DB::table('audit_logs')->insert($row);
|
||||
}
|
||||
public function test_native_page_matches_live_documents_and_existing_financial_positions():void
|
||||
{
|
||||
$old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$new=$this->service()->listing($this->reader);$this->assertSame(Payables::canonical($old['documents']),Payables::canonical($new['documents']));$this->assertSame($this->positions($old),$this->positions($new));$o=$new['obligations'][0];$this->assertSame(6000,$o['principal_minor']);$this->assertSame(3000,$o['balance_minor']);$this->assertSame(1000,$o['pending_payments_minor']);$this->assertSame(500,$o['rejected_payments_minor']);$this->assertNull($o['due_on']);$this->assertFalse($new['permissions']['prepare']);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',1)->assertJsonPath('obligations.0.balance_minor',3000);
|
||||
}
|
||||
public function test_native_invoice_filter_survives_document_and_evidence_pagination():void
|
||||
{
|
||||
$this->clones(50);$a=$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',51)->assertJsonCount(25,'documents')->json('documents');$b=$this->getJson('/api/supplier-payables?page=2')->assertOk()->assertJsonCount(25,'documents')->json('documents');$this->assertEmpty(array_intersect(array_column($a,'local_id'),array_column($b,'local_id')));$this->assertGreaterThan($b[0]['local_id'],$a[24]['local_id']);
|
||||
$id=$this->f['invoice']->id;$this->getJson('/api/supplier-payables?invoice='.$id)->assertOk()->assertJsonPath('documents.0.local_id',$id)->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonPath('listing_query.invoice',$id);$this->getJson('/api/supplier-payables?recognition=recognised')->assertOk()->assertJsonPath('pagination.documents.total',1);$this->getJson('/api/supplier-payables?recognition=unrecognised&per_page=100')->assertOk()->assertJsonPath('pagination.documents.total',50)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?q=Page%20Alpha')->assertOk()->assertJsonPath('pagination.documents.total',25);$this->getJson('/api/supplier-payables?q=%25')->assertOk()->assertJsonPath('pagination.documents.total',0);
|
||||
$page=$this->get('/obrigacoes-fornecedores?invoice='.$id)->assertOk()->getContent();$this->assertTrue(str_contains($page,'action="/obrigacoes-fornecedores"'),'Native page action retained');$this->assertTrue(str_contains($page,'name="invoice"'),'Invoice filter retained in search form');$this->assertTrue(str_contains($page,'value="'.$id.'"'),'Original invoice selection retained');
|
||||
}
|
||||
public function test_native_page_batches_sources_and_payment_audits_without_writer_locks():void
|
||||
{
|
||||
$this->clones(50);$queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture)$queries[]=$q->sql;});$this->service()->listing($this->reader);$capture=false;$small=count($queries);$this->assertEmpty(array_filter($queries,fn($q)=>str_contains(strtolower($q),'for update')));$queries=[];$capture=true;$data=$this->service()->listing($this->reader,null,['per_page'=>100]);$capture=false;$this->assertCount(51,$data['documents']);$this->assertSame($small+2,count($queries),'Only two nonempty proposal/event data queries are added when original recognised invoice enters the window');$this->assertCount(2,array_filter($queries,fn($q)=>str_contains($q,'from "audit_logs"')));$this->assertCount(1,array_filter($queries,fn($q)=>str_contains($q,'from "external_payments"')));
|
||||
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-query.json',json_encode(['documents'=>51,'page_sizes'=>[25,100],'page25_queries'=>$small,'page100_queries'=>count($queries),'extra_queries_reason'=>'Proposal/event data SELECTs skipped by paginator for empty evidence windows.','audit_selects'=>2,'payment_selects'=>1,'no_writer_lock'=>true],JSON_PRETTY_PRINT));
|
||||
}
|
||||
public function test_native_page_refuses_tenant_leaks_revoked_rights_and_stale_role():void
|
||||
{
|
||||
DB::table('pa_access')->where('user_id',$this->reader->id)->delete();$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('pa_access')->insert(['tenant_id'=>$this->reader->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$this->reader->id,'person'=>'native-page-person-'.$this->reader->id,'action'=>'view']);DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'requester']);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'finance']);
|
||||
$tenant=Tenant::query()->create(['name'=>'Foreign native page tenant']);$other=User::factory()->create(['tenant_id'=>$tenant->id,'purchasing_role'=>PurchasingRole::Finance]);DB::table('pa_access')->insert(['tenant_id'=>$tenant->id,'company_id'=>0,'unit_id'=>0,'user_id'=>$other->id,'person'=>'foreign-native-page','action'=>'view']);$this->actingAs($other);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',0)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertNotFound();
|
||||
}
|
||||
public function test_native_audit_changes_require_new_conference_without_using_read_snapshots():void
|
||||
{
|
||||
$this->assertSame(3000,$this->service()->listing($this->reader)['obligations'][0]['balance_minor']);$this->duplicateRegistration();$new=$this->service()->listing($this->reader);$old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$this->assertSame($this->positions($old),$this->positions($new));$this->assertNull($new['obligations'][0]['balance_minor']);$this->assertTrue($new['obligations'][0]['source_conflict']);$this->actingAs($this->f['author']);$r=$this->service()->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic new audit after read.',(string)Str::uuid());$this->assertSame('conflict',$r['state']);$this->assertSame(1,DB::table('pa_obligations')->where('tenant_id',$this->f['tenant']->id)->count());
|
||||
}
|
||||
public function test_native_feature_disabled_preserves_legacy_and_invalid_filters_are_refused():void
|
||||
{
|
||||
foreach(['page=0','per_page=1000','tenant_id=999','recognition=paid','q%5Ba%5D=x'] as $q)$this->getJson('/api/supplier-payables?'.$q)->assertStatus(422);config(['common-payables.paged_read_enabled'=>false]);$this->getJson('/api/supplier-payables?page=1')->assertStatus(503);$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonMissingPath('pagination');
|
||||
}
|
||||
public function test_native_profiles_expiry_maintenance_and_people_context_do_not_grant_financial_read():void
|
||||
{
|
||||
\supplyGrant($this->reader,['finance']);DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>now()->subMinute()]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>null,'maintenance'=>true]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['maintenance'=>false]);
|
||||
$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(['people.internal'])]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('obligations.0.balance_minor',3000);
|
||||
}
|
||||
public function test_native_read_finishes_before_writer_release_and_sees_committed_audits_only():void
|
||||
{
|
||||
$dir=base_path('../private/native-page-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$process=null;DB::beginTransaction();
|
||||
try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->firstOrFail();$this->duplicateRegistration();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-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]);$deadline=microtime(true)+8;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running'],'Read must not wait for writer tenant lock');$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(200,$r['status']);$this->assertSame([3000],$r['balances']);$this->assertSame(1,DB::transactionLevel());file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-concurrency.json',json_encode(['reader_completed_before_writer_release'=>true,'uncommitted_audit_not_observed'=>true,'balance_minor'=>3000,'reader_time_ms'=>$r['time_ms']],JSON_PRETTY_PRINT));}
|
||||
finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
||||
}
|
||||
public function test_native_missing_package_capability_is_503_without_legacy_fallback():void
|
||||
{
|
||||
$dir=base_path('../private/native-page-package-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$pipes=[];$environment=getenv();$environment['COMMON_PAYABLES_PATH']='C:/laragon/www/compras/packages/payables';$p=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path(),$environment);$this->assertIsResource($p);fclose($pipes[0]);$this->assertSame(0,proc_close($p));$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(503,$r['status']);
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,43 @@
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Routing\Exceptions\StreamedResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/PaymentFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
// Reports deliberately require their own read snapshot, outside the test wrapper.
|
||||
// RefreshDatabase detects the completed wrapper and remigrates the disposable DB.
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 1) {
|
||||
throw new LogicException('Relatórios PostgreSQL apenas na base descartável e transacção de teste esperadas.');
|
||||
}
|
||||
DB::commit();
|
||||
}
|
||||
$this->actingAs($this->f['issuer']);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
if (! app()->environment('testing') || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 0) {
|
||||
throw new LogicException('Limpeza apenas do schema da base descartável fora de transacções.');
|
||||
}
|
||||
// Committed fixtures require schema cleanup, including functions migrate:fresh leaves behind.
|
||||
DB::statement('DROP SCHEMA public CASCADE');
|
||||
DB::statement('CREATE SCHEMA public');
|
||||
}
|
||||
});
|
||||
|
||||
function reportCopy(Requisition $original, string $number, array $attributes = []): Requisition
|
||||
{
|
||||
$copy = $original->replicate();
|
||||
@@ -26,6 +48,110 @@ function reportCopy(Requisition $original, string $number, array $attributes = [
|
||||
return $copy;
|
||||
}
|
||||
|
||||
test('needed attention report filters all pages and CSV by native status and needed date without writes', function () {
|
||||
$this->freezeTime();
|
||||
foreach (range(1, 27) as $index) {
|
||||
reportCopy($this->f['r'], 'ATTENTION-'.$index, ['status' => $index === 1 ? 'draft' : 'pending', 'needed_by' => today()->subDay()->toDateString()]);
|
||||
}
|
||||
foreach (['approved', 'rejected', 'cancelled'] as $status) {
|
||||
reportCopy($this->f['r'], 'CLOSED-'.$status, ['status' => $status, 'needed_by' => today()->subDay()->toDateString()]);
|
||||
}
|
||||
reportCopy($this->f['r'], 'NEEDED-TODAY', ['status' => 'pending', 'needed_by' => today()->toDateString()]);
|
||||
reportCopy($this->f['r'], 'NEEDED-FUTURE', ['status' => 'draft', 'needed_by' => today()->addDay()->toDateString()]);
|
||||
$before = [];
|
||||
foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 27)->has('records.data', 25)
|
||||
->where('insights.unapproved_past_needed_date', 27)->where('counts.pending', 26)->where('counts.draft', 1)
|
||||
->where('records.data.0.needed_by', today()->subDay()->toDateString())
|
||||
->where('records.next_page_url', fn ($url) => str_contains($url, 'attention=unapproved_needed')));
|
||||
$this->get('/relatorios?attention=unapproved_needed&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', 2));
|
||||
$csv = $this->get('/relatorios/compras.csv?attention=unapproved_needed&page=2')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain('ATTENTION-1')->toContain('ATTENTION-27')->toContain('Data de necessidade')->not->toContain('CLOSED-')->not->toContain('NEEDED-TODAY')->not->toContain('NEEDED-FUTURE');
|
||||
$this->get('/relatorios?attention=unapproved_needed&status=approved')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
$this->getJson('/relatorios?attention=unknown')->assertUnprocessable();
|
||||
$this->getJson('/relatorios/compras.csv?attention=unknown')->assertUnprocessable();
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
$this->travel(1)->days();
|
||||
$this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 28));
|
||||
});
|
||||
|
||||
test('needed attention report rechecks stream access and preserves department boundaries', function () {
|
||||
$past = reportCopy($this->f['r'], 'OWN-ATTENTION', ['status' => 'pending', 'needed_by' => today()->subDay()->toDateString()]);
|
||||
$head = User::factory()->create(['tenant_id' => $past->tenant_id, 'department_id' => $past->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$otherDepartment = Department::query()->create(['tenant_id' => $past->tenant_id, 'code' => 'ATT-OTHER', 'name' => 'Outro departamento']);
|
||||
reportCopy($past, 'PRIVATE-OTHER-DEPARTMENT', ['department_id' => $otherDepartment->id]);
|
||||
$this->actingAs($head)->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 1));
|
||||
expect($this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk()->streamedContent())->not->toContain('PRIVATE-OTHER-DEPARTMENT');
|
||||
$response = $this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk();
|
||||
$head->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||
$bufferLevel = ob_get_level();
|
||||
try {
|
||||
$response->streamedContent();
|
||||
test()->fail('Revoked report access must fail before streaming');
|
||||
} catch (StreamedResponseException $error) {
|
||||
expect($error->getInnerException())->toBeInstanceOf(HttpException::class);
|
||||
expect($error->getInnerException()->getStatusCode())->toBe(403);
|
||||
} finally {
|
||||
while (ob_get_level() > $bufferLevel) {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('approved attention reports reuse registered quote and visible order facts across every page and CSV', function () {
|
||||
foreach (range(1, 27) as $index) {
|
||||
reportCopy($this->f['r'], 'EMPTY-APPROVED-'.$index);
|
||||
}
|
||||
$withQuote = reportCopy($this->f['r'], 'REGISTERED-NO-ORDER');
|
||||
$quote = Quotation::query()->where('requisition_id', $this->f['r']->id)->firstOrFail()->replicate();
|
||||
$quote->requisition_id = $withQuote->id;
|
||||
$quote->reference = 'REGISTERED-EXPIRED';
|
||||
$quote->valid_until = today()->subDay();
|
||||
$quote->save();
|
||||
reportCopy($this->f['r'], 'PENDING-NO-QUOTE', ['status' => 'pending']);
|
||||
$before = [];
|
||||
foreach (['requisitions', 'quotations', 'purchase_orders', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$this->get('/relatorios')->assertInertia(fn (Assert $p) => $p->where('insights.approved.without_registered_quotes', 27)->where('insights.approved.without_visible_order', 28));
|
||||
foreach (['approved_without_quotes' => 27, 'approved_without_order' => 28] as $attention => $total) {
|
||||
$base = '/relatorios?attention='.$attention;
|
||||
$this->get($base)->assertInertia(fn (Assert $p) => $p->where('records.total', $total)->has('records.data', 25)->where('counts.approved', $total)
|
||||
->where('records.next_page_url', fn ($url) => str_contains($url, 'attention='.$attention)));
|
||||
$this->get($base.'&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', $total - 25));
|
||||
$csv = $this->get('/relatorios/compras.csv?attention='.$attention.'&page=2')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain('EMPTY-APPROVED-1')->toContain('EMPTY-APPROVED-27')->not->toContain('PENDING-NO-QUOTE');
|
||||
if ($attention === 'approved_without_quotes') {
|
||||
expect($csv)->not->toContain('REGISTERED-NO-ORDER');
|
||||
} else {
|
||||
expect($csv)->toContain('REGISTERED-NO-ORDER');
|
||||
}
|
||||
$this->get($base.'&status=pending')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
}
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
});
|
||||
|
||||
test('approved attention reports keep department and company scopes while finding registered facts', function () {
|
||||
$own = reportCopy($this->f['r'], 'OWN-EMPTY-APPROVED');
|
||||
$department = Department::query()->create(['tenant_id' => $own->tenant_id, 'name' => 'Departamento reservado']);
|
||||
reportCopy($own, 'PRIVATE-OTHER-DEPT', ['department_id' => $department->id]);
|
||||
$head = User::factory()->create(['tenant_id' => $own->tenant_id, 'department_id' => $own->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$foreign = receiptFixture();
|
||||
reportCopy($foreign['r'], 'PRIVATE-OTHER-COMPANY');
|
||||
$this->actingAs($head);
|
||||
foreach (['approved_without_quotes', 'approved_without_order'] as $attention) {
|
||||
$this->get('/relatorios?attention='.$attention)->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $own->id));
|
||||
$csv = $this->get('/relatorios/compras.csv?attention='.$attention)->assertOk()->streamedContent();
|
||||
expect($csv)->toContain('OWN-EMPTY-APPROVED')->not->toContain('PRIVATE-OTHER-DEPT')->not->toContain('PRIVATE-OTHER-COMPANY');
|
||||
}
|
||||
});
|
||||
|
||||
test('report entry connects visible requests to the existing process and report without business writes', function () {
|
||||
$audits = AuditLog::query()->count();
|
||||
$this->get('/relatorios')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\ReceiptDocument;
|
||||
use App\Services\ReceiptDocuments;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
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();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f));
|
||||
$this->directory = storage_path('framework/testing/receipt-block-'.Str::uuid());
|
||||
Storage::set('receipt_documents', Storage::build(['driver' => 'local', 'root' => $this->directory, 'throw' => true]));
|
||||
$this->image = UploadedFile::fake()->createWithContent('guia.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6E4sAAAAASUVORK5CYII='));
|
||||
$this->key = (string) Str::uuid();
|
||||
$this->url = '/recepcoes/'.$this->receipt->id;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->directory);
|
||||
});
|
||||
|
||||
function receiptDocumentInput(object $test, ?string $key = null, ?UploadedFile $file = null): array
|
||||
{
|
||||
return ['operation_key' => $key ?? $test->key, 'document_type' => 'delivery_note', 'file' => $file ?? $test->image];
|
||||
}
|
||||
|
||||
test('receipt document block uploads reuses and downloads evidence without repeating receipt stock or approvals', function () {
|
||||
$receiptBefore = $this->receipt->getAttributes();
|
||||
$before = [];
|
||||
foreach (['goods_receipts', 'goods_receipt_items', 'stock_movements', 'approval_steps', 'budget_entries', 'purchase_orders'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect($this->url.'#receipt-documents');
|
||||
$document = ReceiptDocument::query()->sole();
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertRedirect();
|
||||
expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1);
|
||||
expect(DB::table('audit_logs')->where('action', 'receipt.document_added')->count())->toBe(1);
|
||||
expect(DB::table('audit_logs')->where('action', 'receipt.document_reused')->count())->toBe(1);
|
||||
$this->get($this->url)->assertInertia(fn (Assert $p) => $p->where('documentsReady', true)->where('canAddDocuments', true)
|
||||
->has('documents', 1)->missing('documents.0.path')->where('documents.0.id', $document->id));
|
||||
$response = $this->get($this->url.'/documentos/'.$document->id)->assertOk()->assertHeader('Content-Type', 'application/octet-stream')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Cache-Control', 'no-store, private');
|
||||
expect(hash('sha256', $response->streamedContent()))->toBe($document->sha256);
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
expect($this->receipt->fresh()->getAttributes())->toBe($receiptBefore);
|
||||
});
|
||||
|
||||
test('receipt document block preserves role independence tenant and receipt scopes', function () {
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
|
||||
$document = ReceiptDocument::query()->sole();
|
||||
$this->actingAs($this->f['issuer'])->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertOk();
|
||||
supplyGrant($this->f['receiver'], ['warehouse']);
|
||||
supplyGrant($this->f['issuer'], ['warehouse']);
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['receiver'])->get($this->url.'/documentos/'.$document->id)->assertNotFound();
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertNotFound();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$another = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '0.100'));
|
||||
$this->get('/recepcoes/'.$another->id.'/documentos/'.$document->id)->assertNotFound();
|
||||
supplyGrant($this->f['receiver'], ['warehouse']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]);
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertForbidden();
|
||||
});
|
||||
|
||||
test('receipt document block rejects changed replay and invalid files and detects tampering', function () {
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
|
||||
$document = ReceiptDocument::query()->sole();
|
||||
$this->withHeader('Accept', 'application/json')->post($this->url.'/documentos', receiptDocumentInput($this, null, UploadedFile::fake()->createWithContent('guia.png', file_get_contents($this->image->getPathname()).'changed')))->assertUnprocessable();
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('bad.html', 1, 'text/html')))->assertUnprocessable();
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('large.pdf', 2049, 'application/pdf')))->assertUnprocessable();
|
||||
expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1);
|
||||
foreach (['update', 'delete'] as $operation) {
|
||||
try {
|
||||
DB::transaction(function () use ($document, $operation) {
|
||||
$query = DB::table('receipt_documents')->where('id', $document->id);
|
||||
$operation === 'update' ? $query->update(['original_name' => 'changed.png']) : $query->delete();
|
||||
});
|
||||
test()->fail('Document metadata must remain append-only');
|
||||
} catch (QueryException) {
|
||||
expect($document->fresh()->original_name)->toBe('guia.png');
|
||||
}
|
||||
}
|
||||
Storage::disk('receipt_documents')->put($document->path, 'TAMPERED');
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertStatus(409);
|
||||
Storage::disk('receipt_documents')->delete($document->path);
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertNotFound();
|
||||
});
|
||||
|
||||
test('receipt document block rolls back metadata and private file if audit fails then permits safe retry', function () {
|
||||
$this->partialMock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected document audit failure'));
|
||||
try {
|
||||
app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image);
|
||||
test()->fail('Expected rollback');
|
||||
} catch (RuntimeException $error) {
|
||||
expect($error->getMessage())->toBe('Injected document audit failure');
|
||||
}
|
||||
expect(ReceiptDocument::query()->count())->toBe(0)->and(Storage::disk('receipt_documents')->allFiles())->toBe([]);
|
||||
app()->forgetInstance(RequisitionWorkflow::class);
|
||||
$document = app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image);
|
||||
expect($document->id)->toBeGreaterThan(0)->and(ReceiptDocument::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('receipt document block leaves the established receipt page usable before installation', function () {
|
||||
Schema::drop('receipt_documents');
|
||||
$this->get($this->url)->assertOk()->assertInertia(fn (Assert $p) => $p->where('documentsReady', false)->where('canAddDocuments', false)->has('documents', 0));
|
||||
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertStatus(503);
|
||||
expect(GoodsReceipt::query()->whereKey($this->receipt->id)->exists())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ReplenishmentHeartbeat;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->freezeTime();
|
||||
$this->f = receiptFixture();
|
||||
$this->originalStorage = app()->storagePath();
|
||||
$this->temporaryStorage = $this->originalStorage.'/framework/testing/health-'.Str::uuid();
|
||||
File::ensureDirectoryExists($this->temporaryStorage.'/framework');
|
||||
app()->useStoragePath($this->temporaryStorage);
|
||||
auth()->guard()->logout();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
app()->useStoragePath($this->originalStorage);
|
||||
File::deleteDirectory($this->temporaryStorage);
|
||||
});
|
||||
|
||||
test('replenishment health is read only and emits success without creating work purchases or telemetry', function () {
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0);
|
||||
$path = storage_path('framework/replenishment-command-status.json');
|
||||
$beforeFile = File::get($path);
|
||||
$before = [];
|
||||
foreach (['requisitions', 'stock_movements', 'audit_logs', 'replenishment_work', 'supply_records'] as $table) {
|
||||
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
|
||||
}
|
||||
$result = app(ReplenishmentQueue::class)->health();
|
||||
expect($result['ok'])->toBeTrue()->and($result['reasons'])->toBe([])->and($result['work'])->toBe(['total' => 0, 'pending' => 0, 'failed' => 0]);
|
||||
$this->artisan('sgc:replenishment-health', ['--json' => true])->expectsOutput(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))->assertExitCode(0);
|
||||
expect(auth()->check())->toBeFalse()->and(File::get($path))->toBe($beforeFile);
|
||||
foreach ($before as $table => $rows) {
|
||||
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
|
||||
}
|
||||
});
|
||||
|
||||
test('replenishment health reports missing stale damaged or unsuccessful observation without certifying execution', function () {
|
||||
expect(app(ReplenishmentQueue::class)->health()['reasons'])->toContain('not_recent', 'last_run_not_successful');
|
||||
File::put(storage_path('framework/replenishment-command-status.json'), '{broken');
|
||||
expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeFalse();
|
||||
File::put(storage_path('framework/replenishment-command-status.json'), json_encode(['result' => 'passed', 'finished_at' => now()->subHour()->toIso8601String(), 'installation' => app(ReplenishmentHeartbeat::class)->fingerprint()], JSON_THROW_ON_ERROR));
|
||||
expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['not_recent']);
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 1);
|
||||
expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['last_run_not_successful']);
|
||||
$this->artisan('sgc:replenishment-health')->expectsOutput('Reposição: verificação técnica requer atenção.')->expectsOutput('Última passagem sem sucesso confirmado.')->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('replenishment health aggregates pending failures while the workdesk stays tenant scoped and unchanged', function () {
|
||||
$queue = app(ReplenishmentQueue::class);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$first = $queue->enqueue($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['receiver']);
|
||||
$second = $queue->enqueue($foreign['receiver'], $foreign['stock']->id, $foreign['warehouse']->id);
|
||||
DB::table('replenishment_work')->where('id', $second)->update(['failures' => 1, 'last_error' => 'PRIVATE-WORK-DETAIL']);
|
||||
$before = DB::table('replenishment_work')->orderBy('id')->get()->toArray();
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0);
|
||||
auth()->guard()->logout();
|
||||
$result = $queue->health();
|
||||
expect($result['ok'])->toBeFalse()->and($result['reasons'])->toBe(['pending_signals', 'failed_work'])
|
||||
->and($result['work'])->toBe(['total' => 2, 'pending' => 2, 'failed' => 1]);
|
||||
expect(json_encode($result))->not->toContain('PRIVATE-WORK-DETAIL')->not->toContain('tenant_id')->not->toContain('responsible_id');
|
||||
expect(DB::table('replenishment_work')->orderBy('id')->get()->toArray())->toEqual($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect((int) $queue->status($this->f['receiver'])['work']->total)->toBe(1);
|
||||
expect(DB::table('replenishment_work')->where('id', $first)->value('processed_generation'))->toBe(0);
|
||||
$unassigned = User::factory()->create(['tenant_id' => null]);
|
||||
$this->actingAs($unassigned);
|
||||
expect((int) $queue->status($unassigned)['work']->total)->toBe(0);
|
||||
});
|
||||
|
||||
test('replenishment health rejects interactive identity and unavailable composition without processing work', function () {
|
||||
$this->actingAs($this->f['receiver']);
|
||||
try {
|
||||
app(ReplenishmentQueue::class)->health();
|
||||
test()->fail('Interactive identity must not be used for technical health');
|
||||
} catch (HttpException $error) {
|
||||
expect($error->getStatusCode())->toBe(403);
|
||||
}
|
||||
auth()->guard()->logout();
|
||||
config(['work_context.composition' => 'sgf']);
|
||||
$result = app(ReplenishmentQueue::class)->health();
|
||||
expect($result['ok'])->toBeFalse()->and($result['recovery_ready'])->toBeFalse()->and($result['reasons'])->toContain('not_installed')->and($result['work'])->toBeNull();
|
||||
});
|
||||
|
||||
test('replenishment health command returns generic unavailability without exposing an exception', function () {
|
||||
$this->mock(ReplenishmentQueue::class)->shouldReceive('health')->once()->andThrow(new RuntimeException('PRIVATE-CONNECTION-DETAIL'));
|
||||
$this->artisan('sgc:replenishment-health', ['--json' => true])->expectsOutput(json_encode(['checked_at' => now()->toIso8601String(), 'ok' => false, 'reasons' => ['unavailable']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))->assertExitCode(1);
|
||||
});
|
||||
|
||||
test('replenishment health requires a bound observation and rejects another installation without leaking its identity', function () {
|
||||
$path = storage_path('framework/replenishment-command-status.json');
|
||||
File::put($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
|
||||
expect(app(ReplenishmentQueue::class)->health()['reasons'])->toBe(['unbound_observation']);
|
||||
File::put($path, json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String(), 'installation' => str_repeat('a', 64)], JSON_THROW_ON_ERROR));
|
||||
expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeFalse();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['receiver'])['schedulerRecent'])->toBeFalse();
|
||||
auth()->guard()->logout();
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0);
|
||||
expect(app(ReplenishmentQueue::class)->health()['ok'])->toBeTrue();
|
||||
expect(json_encode(app(ReplenishmentQueue::class)->health()))->not->toContain(app(ReplenishmentHeartbeat::class)->fingerprint());
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ReplenishmentProcessLock;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->freezeTime();
|
||||
$this->originalStorage = app()->storagePath();
|
||||
$this->heartbeatStorage = $this->originalStorage.'/framework/testing/heartbeat-'.Str::uuid();
|
||||
File::ensureDirectoryExists($this->heartbeatStorage.'/framework');
|
||||
app()->useStoragePath($this->heartbeatStorage);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
app()->useStoragePath($this->originalStorage);
|
||||
File::deleteDirectory($this->heartbeatStorage);
|
||||
});
|
||||
|
||||
test('native replenishment command records success or pending recovery without identities', function (int $failed, int $code, string $result) {
|
||||
$queue = $this->mock(ReplenishmentQueue::class);
|
||||
$queue->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$queue->shouldReceive('discover')->once();
|
||||
$queue->shouldReceive('drain')->with(12)->once()->andReturn(['processed' => 2, 'failed' => $failed]);
|
||||
$this->artisan('sgc:replenishment-tick', ['--limit' => 12])->assertExitCode($code);
|
||||
$data = json_decode(File::get(storage_path('framework/replenishment-command-status.json')), true, 32, JSON_THROW_ON_ERROR);
|
||||
expect($data)->toHaveCount(6)->and($data['result'])->toBe($result)->and($data['exit_code'])->toBe($code)
|
||||
->and($data['finished_at'])->toBe(now()->toIso8601String());
|
||||
})->with([[0, 0, 'passed'], [1, 1, 'retry_pending']]);
|
||||
|
||||
test('interrupted discovery replaces a previous success with recovery pending and preserves the original error', function () {
|
||||
File::put(storage_path('framework/replenishment-command-status.json'), '{"result":"passed"}');
|
||||
$queue = $this->mock(ReplenishmentQueue::class);
|
||||
$queue->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$queue->shouldReceive('discover')->once()->andThrow(new RuntimeException('Synthetic discovery interruption'));
|
||||
$queue->shouldNotReceive('drain');
|
||||
try {
|
||||
$this->artisan('sgc:replenishment-tick')->run();
|
||||
test()->fail('Expected discovery failure');
|
||||
} catch (RuntimeException $error) {
|
||||
expect($error->getMessage())->toBe('Synthetic discovery interruption');
|
||||
}
|
||||
$data = json_decode(File::get(storage_path('framework/replenishment-command-status.json')), true, 32, JSON_THROW_ON_ERROR);
|
||||
expect($data['result'])->toBe('retry_pending')->and($data['exit_code'])->toBe(1);
|
||||
expect(File::get(storage_path('framework/replenishment-command-status.json')))->not->toContain('Synthetic discovery interruption');
|
||||
});
|
||||
|
||||
test('invalid limits do not publish a successful scheduler observation', function () {
|
||||
$queue = $this->mock(ReplenishmentQueue::class);
|
||||
$queue->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$queue->shouldNotReceive('discover');
|
||||
$queue->shouldNotReceive('drain');
|
||||
$this->artisan('sgc:replenishment-tick', ['--limit' => 501])->assertExitCode(2);
|
||||
expect(File::exists(storage_path('framework/replenishment-command-status.json')))->toBeFalse();
|
||||
});
|
||||
|
||||
test('overlapping native command neither processes work nor replaces the last completed observation', function () {
|
||||
$held = new ReplenishmentProcessLock;
|
||||
expect($held->acquire())->toBeTrue();
|
||||
$path = storage_path('framework/replenishment-command-status.json');
|
||||
File::put($path, '{"result":"retry_pending","finished_at":"previous"}');
|
||||
$before = File::get($path);
|
||||
$queue = $this->mock(ReplenishmentQueue::class);
|
||||
$queue->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$queue->shouldNotReceive('discover');
|
||||
$queue->shouldNotReceive('drain');
|
||||
try {
|
||||
$this->artisan('sgc:replenishment-tick')->expectsOutput('Verificação periódica já em execução; aguarde a próxima passagem.')->assertExitCode(1);
|
||||
expect(File::get($path))->toBe($before);
|
||||
} finally {
|
||||
$held->release();
|
||||
}
|
||||
$next = new ReplenishmentProcessLock;
|
||||
expect($next->acquire())->toBeTrue();
|
||||
$next->release();
|
||||
});
|
||||
|
||||
test('exception and successful runs both release native process exclusion', function () {
|
||||
$queue = $this->mock(ReplenishmentQueue::class);
|
||||
$queue->shouldReceive('ready')->twice()->andReturnTrue();
|
||||
$queue->shouldReceive('discover')->once()->andThrow(new RuntimeException('Synthetic interruption'));
|
||||
try {
|
||||
$this->artisan('sgc:replenishment-tick')->run();
|
||||
test()->fail('Expected interruption');
|
||||
} catch (RuntimeException $error) {
|
||||
expect($error->getMessage())->toBe('Synthetic interruption');
|
||||
}
|
||||
$queue->shouldReceive('discover')->once();
|
||||
$queue->shouldReceive('drain')->once()->andReturn(['processed' => 0, 'failed' => 0]);
|
||||
$this->artisan('sgc:replenishment-tick')->assertExitCode(0);
|
||||
$next = new ReplenishmentProcessLock;
|
||||
expect($next->acquire())->toBeTrue();
|
||||
$next->release();
|
||||
});
|
||||
|
||||
test('destroyed worker lock can be acquired without deleting its control file', function () {
|
||||
$first = new ReplenishmentProcessLock;
|
||||
expect($first->acquire())->toBeTrue();
|
||||
$contender = new ReplenishmentProcessLock;
|
||||
expect($contender->acquire())->toBeFalse();
|
||||
unset($first);
|
||||
expect(File::exists(storage_path('framework/replenishment-command.lock')))->toBeTrue();
|
||||
expect($contender->acquire())->toBeTrue();
|
||||
$contender->release();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ReplenishmentProcessLock;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
test('terminated external worker releases the native operating system lock without cleanup', function () {
|
||||
$originalStorage = app()->storagePath();
|
||||
$temporary = $originalStorage.'/framework/testing/process-recovery-'.Str::uuid();
|
||||
File::ensureDirectoryExists($temporary.'/framework');
|
||||
app()->useStoragePath($temporary);
|
||||
$path = storage_path('framework/replenishment-command.lock');
|
||||
$worker = new Process([PHP_BINARY, '-r', '$h = fopen($argv[1], "c"); if (!$h || !flock($h, LOCK_EX | LOCK_NB)) { exit(2); } echo "LOCKED"; fflush(STDOUT); sleep(30);', $path], timeout: 10);
|
||||
$contender = new ReplenishmentProcessLock;
|
||||
try {
|
||||
$worker->start();
|
||||
expect($worker->waitUntil(fn ($type, $output) => str_contains($output, 'LOCKED')))->toBeTrue();
|
||||
expect($contender->acquire())->toBeFalse();
|
||||
$worker->stop(0);
|
||||
expect($worker->isRunning())->toBeFalse();
|
||||
expect(File::exists($path))->toBeTrue();
|
||||
expect($contender->acquire())->toBeTrue();
|
||||
} finally {
|
||||
$worker->stop(0);
|
||||
$contender->release();
|
||||
app()->useStoragePath($originalStorage);
|
||||
File::deleteDirectory($temporary);
|
||||
}
|
||||
});
|
||||
@@ -6,16 +6,14 @@ use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
test('security page is displayed', function () {
|
||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
||||
|
||||
Features::twoFactorAuthentication([
|
||||
config(['fortify.features' => [Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
]);
|
||||
])]]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp])
|
||||
->get(route('security.edit'))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/security')
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
require_once __DIR__.'/../../Support/FortifyMfa.php';
|
||||
|
||||
test('mfa management requires recent password confirmation without mutating enrollment on consultation', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/settings/security')->assertRedirect('/user/confirm-password');
|
||||
$this->postJson('/user/two-factor-authentication')->assertStatus(423);
|
||||
expect($user->fresh()->two_factor_secret)->toBeNull();
|
||||
$this->withSession(['auth.password_confirmed_at' => now()->timestamp])->get('/settings/security')->assertOk()
|
||||
->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p
|
||||
->where('canManageTwoFactor', true)->where('twoFactorEnabled', false)->where('twoFactorPending', false)->missing('secretKey')->missing('recoveryCodes'));
|
||||
expect($user->fresh()->two_factor_secret)->toBeNull();
|
||||
});
|
||||
|
||||
test('mfa management completes native enrollment renewal and disable only for the current account', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp]);
|
||||
$this->postJson('/user/two-factor-authentication', ['user_id' => $other->id])->assertSuccessful();
|
||||
$secret = $this->getJson('/user/two-factor-secret-key')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->json('secretKey');
|
||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||
$before = $user->fresh()->getAttributes();
|
||||
$this->get('/settings/security')->assertInertia(fn (Assert $p) => $p->where('twoFactorPending', true)->where('twoFactorEnabled', false)->missing('secretKey')->missing('recoveryCodes'));
|
||||
expect($user->fresh()->getAttributes())->toBe($before);
|
||||
$code = (new Google2FA)->getCurrentOtp($secret);
|
||||
$this->postJson('/user/confirmed-two-factor-authentication', ['code' => $code])->assertSuccessful();
|
||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
|
||||
$oldCodes = $this->getJson('/user/two-factor-recovery-codes')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->json();
|
||||
$this->postJson('/user/two-factor-recovery-codes')->assertSuccessful();
|
||||
$newCodes = $this->getJson('/user/two-factor-recovery-codes')->assertOk()->json();
|
||||
expect($oldCodes)->toHaveCount(8)->and($newCodes)->toHaveCount(8)->and(array_intersect($oldCodes, $newCodes))->toBe([]);
|
||||
$this->withSession(['auth.password_confirmed_at' => 0])->deleteJson('/user/two-factor-authentication')->assertStatus(423);
|
||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
|
||||
$this->withSession(['auth.password_confirmed_at' => now()->timestamp])->deleteJson('/user/two-factor-authentication')->assertSuccessful();
|
||||
expect($user->fresh()->two_factor_secret)->toBeNull()->and($user->fresh()->two_factor_recovery_codes)->toBeNull()->and($user->fresh()->two_factor_confirmed_at)->toBeNull();
|
||||
expect($other->fresh()->two_factor_secret)->toBeNull();
|
||||
});
|
||||
|
||||
test('mfa management invalid confirmation does not flash authentication codes or activate the account', function () {
|
||||
foundationMfaRoutes();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp]);
|
||||
$this->postJson('/user/two-factor-authentication')->assertSuccessful();
|
||||
$this->from('/settings/security')->post('/user/confirmed-two-factor-authentication', ['code' => 'invalid'])->assertSessionHasErrorsIn('confirmTwoFactorAuthentication', ['code'])
|
||||
->assertSessionMissing('_old_input.code');
|
||||
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||
});
|
||||
|
||||
test('mfa management rejects missing csrf and accepts the encrypted xsrf cookie header used by the interface', function () {
|
||||
foundationMfaRoutes();
|
||||
$this->instance(PreventRequestForgery::class,
|
||||
new class(app(), app('encrypter')) extends PreventRequestForgery
|
||||
{
|
||||
protected function runningUnitTests(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
$user = User::factory()->create();
|
||||
$response = $this->actingAs($user)->withSession(['auth.password_confirmed_at' => now()->timestamp])->get('/settings/security')->assertOk();
|
||||
$cookie = $response->getCookie('XSRF-TOKEN', false);
|
||||
expect($cookie)->not->toBeNull();
|
||||
$this->postJson('/user/two-factor-authentication')->assertStatus(419);
|
||||
expect($user->fresh()->two_factor_secret)->toBeNull();
|
||||
$this->withHeader('X-XSRF-TOKEN', $cookie->getValue())->postJson('/user/two-factor-authentication')->assertSuccessful();
|
||||
expect($user->fresh()->two_factor_secret)->not->toBeNull();
|
||||
});
|
||||
@@ -9,12 +9,15 @@ use App\Models\SupplierEvaluation;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierEvaluationWorkflow;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
@@ -163,3 +166,96 @@ test('a newly declared process conflict prevents review of the stored evaluation
|
||||
$this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa após declaração de conflito'])->assertForbidden();
|
||||
expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2);
|
||||
});
|
||||
|
||||
function qualityEvaluationTasks(object $test, User $actor): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'),
|
||||
fn ($row) => str_starts_with($row['id'], 'supplier-evaluation:')));
|
||||
}
|
||||
|
||||
test('quality task follows preparation independent review return and approval', function () {
|
||||
$e = evaluationDraft($this);
|
||||
expect(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1)
|
||||
->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->actingAs($this->f['issuer'])->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertRedirect();
|
||||
expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$tasks = qualityEvaluationTasks($this, $this->reviewer);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_evaluation_review')->and($tasks[0]['due_on'])->toBeNull();
|
||||
$this->get($tasks[0]['href'])->assertInertia(fn (Assert $p) => $p->where('canReview', true));
|
||||
$this->post('/fornecedores/avaliacoes/'.$e->id.'/return', ['version' => 2, 'reason' => 'Conferir novamente o âmbito e as evidências.'])->assertRedirect();
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1);
|
||||
$this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 3])->assertRedirect();
|
||||
$this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 4, 'reason' => 'Âmbito e evidências conferidos independentemente.'])->assertRedirect();
|
||||
expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([])->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]);
|
||||
$before = $e->refresh()->getAttributes();
|
||||
qualityEvaluationTasks($this, $this->reviewer);
|
||||
expect($e->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('quality task excludes all contributors after shared editing and does not publish conclusions', function () {
|
||||
$e = evaluationDraft($this);
|
||||
$this->actingAs($this->reviewer)->put('/fornecedores/avaliacoes/'.$e->id, ['version' => 1, 'payload' => [...$this->data['payload'], 'scope' => 'Âmbito corrigido por outro preparador.']])->assertRedirect();
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1);
|
||||
$this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 2])->assertRedirect();
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$tasks = qualityEvaluationTasks($this, $independent);
|
||||
expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('quality task excludes foreign company department and current process conflict', function () {
|
||||
$e = submittedEvaluation($this);
|
||||
$other = receiptFixture();
|
||||
expect(qualityEvaluationTasks($this, $other['issuer']))->toBe([]);
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
expect(qualityEvaluationTasks($this, $head))->toBe([]);
|
||||
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id,
|
||||
'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício no processo avaliado.']);
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false));
|
||||
});
|
||||
|
||||
test('quality task keeps old pending evaluations visible through pagination without writing', function () {
|
||||
$e = submittedEvaluation($this);
|
||||
for ($i = 0; $i < 104; $i++) {
|
||||
$copy = $e->replicate();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->save();
|
||||
}
|
||||
$before = AuditLog::query()->count();
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(105);
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=AVA-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-evaluation:'.$e->id.':review:2'));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=AVA-&page=5')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
expect(AuditLog::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('quality task blocks maintenance writes and expired memberships using current authority', function () {
|
||||
$e = submittedEvaluation($this);
|
||||
supplyGrant($this->reviewer, ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('canCreate', false));
|
||||
$this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false));
|
||||
$this->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa com acesso apenas para manutenção.'])->assertForbidden();
|
||||
$this->post('/fornecedores/avaliacoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['expires_at' => now()->subMinute()]);
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and($e->refresh()->status)->toBe('review');
|
||||
});
|
||||
|
||||
test('quality task and commands reject a revoked role even with a previously loaded actor', function () {
|
||||
$e = submittedEvaluation($this);
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1);
|
||||
DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']);
|
||||
expect(qualityEvaluationTasks($this, $this->reviewer->fresh()))->toBe([]);
|
||||
$this->actingAs($this->reviewer);
|
||||
try {
|
||||
app(SupplierEvaluationWorkflow::class)->transition($this->reviewer, $e->id, 2, 'approve', 'Tentativa com instância anterior à revogação.');
|
||||
$this->fail('O papel revogado não pode decidir.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(403);
|
||||
}
|
||||
expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2);
|
||||
});
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierImprovementWorkflow;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
use Illuminate\Database\QueryException;
|
||||
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();
|
||||
@@ -154,3 +156,79 @@ test('audit failure rolls a measure and its progress back without changing the e
|
||||
expect(fn () => app(SupplierImprovementWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $m->id, 'progress', ['version' => 1, ...$this->execution]))->toThrow(RuntimeException::class);
|
||||
expect($m->refresh()->version)->toBe(1)->and($m->result)->toBeNull()->and($this->evaluation->refresh()->status)->toBe('approved');
|
||||
});
|
||||
|
||||
function improvementTasks(object $test, User $actor): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'),
|
||||
fn ($row) => str_starts_with($row['id'], 'supplier-improvement:')));
|
||||
}
|
||||
|
||||
test('improvement tasks follow assignment independent review return and closure without changing the approved source', function () {
|
||||
$before = $this->evaluation->refresh()->getAttributes();
|
||||
$m = improvementDraft($this);
|
||||
expect(improvementTasks($this, $this->planner))->toBe([]);
|
||||
$tasks = improvementTasks($this, $this->f['issuer']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['due_on'])->toBe($this->data['due_on'])
|
||||
->and($tasks[0]['href'])->toBe('/fornecedores/avaliacoes/'.$this->evaluation->id.'#medida-'.$m->id);
|
||||
$this->post($this->url.'/'.$m->id.'/submit', ['version' => 1, ...$this->execution])->assertRedirect();
|
||||
expect(improvementTasks($this, $this->f['issuer']))->toBe([])
|
||||
->and(improvementTasks($this, $this->planner))->toBe([]);
|
||||
$tasks = improvementTasks($this, $this->closer);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_improvement_review');
|
||||
$this->post($this->url.'/'.$m->id.'/return', ['version' => 2, 'reason' => 'Completar referência da evidência apresentada.'])->assertRedirect();
|
||||
expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toHaveCount(1);
|
||||
$this->post($this->url.'/'.$m->id.'/submit', ['version' => 3, ...$this->execution])->assertRedirect();
|
||||
$this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 4, 'reason' => 'Conferência independente da execução e evidência.'])->assertRedirect();
|
||||
expect(improvementTasks($this, $this->closer))->toBe([])->and(improvementTasks($this, $this->f['issuer']))->toBe([])
|
||||
->and($this->evaluation->refresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('improvement tasks exclude other companies departments and current conflicts', function () {
|
||||
improvementInReview($this);
|
||||
$other = receiptFixture();
|
||||
expect(improvementTasks($this, $other['issuer']))->toBe([]);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
expect(improvementTasks($this, $head))->toBe([]);
|
||||
expect(improvementTasks($this, $this->closer))->toHaveCount(1);
|
||||
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->closer->id, 'requisition_id' => $this->f['r']->id,
|
||||
'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício para testar o encaminhamento.']);
|
||||
expect(improvementTasks($this, $this->closer))->toBe([]);
|
||||
});
|
||||
|
||||
test('improvement tasks retain old pending measures beyond the first hundred and disclose no evidence', function () {
|
||||
$m = improvementDraft($this);
|
||||
for ($i = 0; $i < 104; $i++) {
|
||||
$copy = $m->replicate();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->save();
|
||||
}
|
||||
$before = AuditLog::query()->count();
|
||||
$tasks = improvementTasks($this, $this->f['issuer']);
|
||||
expect($tasks)->toHaveCount(105)->and($tasks[0]['id'])->toStartWith('supplier-improvement:'.$m->id.':')
|
||||
->and(array_key_exists('evidence', $tasks[0]))->toBeFalse()->and(AuditLog::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('improvement task eligibility and page actions share the current role and approved evaluation', function () {
|
||||
$m = improvementInReview($this);
|
||||
expect(improvementTasks($this, $this->closer))->toHaveCount(1);
|
||||
$this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', true));
|
||||
DB::table('users')->where('id', $this->closer->id)->update(['purchasing_role' => 'requester']);
|
||||
expect(improvementTasks($this, $this->closer->fresh()))->toBe([]);
|
||||
$this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa após revogação da função anterior.'])->assertForbidden();
|
||||
expect($m->refresh()->status)->toBe('review');
|
||||
});
|
||||
|
||||
test('improvement tasks keep maintenance accounts read only and expired memberships ineligible', function () {
|
||||
$m = improvementInReview($this);
|
||||
supplyGrant($this->closer, ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
expect(improvementTasks($this, $this->closer))->toBe([]);
|
||||
$this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canReview', false));
|
||||
$this->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa de decisão com acesso de manutenção.'])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->closer->id)->update(['expires_at' => now()->subMinute()]);
|
||||
expect(improvementTasks($this, $this->closer))->toBe([]);
|
||||
expect($m->refresh()->status)->toBe('review');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Controllers\SupplierController;
|
||||
use App\Http\Requests\SaveSupplierRequest;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Supplier;
|
||||
@@ -8,9 +10,14 @@ use App\Models\SupplierDocument;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierParticipation;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
function maintenanceSupplierData(): array
|
||||
{
|
||||
@@ -145,3 +152,77 @@ test('file is cleaned up and database rolled back if audit cannot be written', f
|
||||
expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]);
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1);
|
||||
});
|
||||
test('supplier task connects independent review current forms and versioned edits without writing on reads', function () {
|
||||
$this->supplier->update(['status' => 'pending']);
|
||||
supplyGrant($this->buyer, ['procurement', 'finance']);
|
||||
supplyGrant($this->finance, ['finance']);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false));
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'supplier_review')->where('tasks.data.0.href', $this->url.'#supplier-review')
|
||||
->missing('tasks.data.0.email')->missing('tasks.data.0.beneficiaries')->where('tasks.data.0.due_on', null));
|
||||
$before = [Supplier::query()->count(), AuditLog::query()->count()];
|
||||
$this->get($this->url)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->where('canReview', true)->where('supplier.version', 1));
|
||||
expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before);
|
||||
$this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Conferência independente da ficha sintética.'])->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'name' => 'Ficha revista para novo uso', 'version' => 2])->assertRedirect();
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.id', 'supplier:'.$this->supplier->id.':review:3'));
|
||||
$this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Tentativa antiga que não deve sobrescrever a ficha.'])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('supplier task excludes the last editor and preserves activation checks for blocked or expired sources', function () {
|
||||
$this->supplier->update(['status' => 'pending', 'last_changed_by' => $this->finance->id]);
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Não pode conferir a alteração que efectuou.'])->assertForbidden();
|
||||
$this->supplier->update(['last_changed_by' => $this->buyer->id, 'blocked' => true]);
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'O impedimento não pode ser ignorado na activação.'])->assertUnprocessable();
|
||||
$this->supplier->update(['blocked' => false, 'next_review' => today()->subDay()]);
|
||||
$this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'A vigência deve ser renovada na ficha de origem.'])->assertUnprocessable();
|
||||
$this->post($this->url.'/validar', ['version' => 1, 'status' => 'suspended', 'comment' => 'Suspensão sintética sem forçar a activação vencida.'])->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('supplier task retains older pending records across all task pages and direct source links', function () {
|
||||
$this->supplier->update(['status' => 'pending']);
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'last_changed_by' => $this->buyer->id,
|
||||
'name' => 'Ficha sintética '.$index, 'nuit' => str_pad((string) $index, 9, '0', STR_PAD_LEFT), 'status' => 'pending']);
|
||||
}
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)
|
||||
->where('tasks.data.0.href', $this->url.'#supplier-review'));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', true));
|
||||
});
|
||||
|
||||
test('supplier task preserves tenant maintenance expiry and fresh actor authority on native commands', function () {
|
||||
$this->supplier->update(['status' => 'pending']);
|
||||
$foreignTenant = Tenant::query()->create(['name' => 'Outra empresa do ensaio']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $foreignTenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($foreign)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url)->assertNotFound();
|
||||
supplyGrant($this->finance, ['finance']);
|
||||
supplyGrant($this->buyer, ['procurement']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false));
|
||||
$this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Manutenção não deve decidir no percurso de negócio.'])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->buyer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertForbidden();
|
||||
expect(app(SupplierParticipation::class)->canReview($this->finance, $this->supplier))->toBeFalse();
|
||||
});
|
||||
test('supplier task preparation cannot borrow a cached role revoked after form validation', function () {
|
||||
$input = SaveSupplierRequest::create('/fornecedores', 'POST', [...maintenanceSupplierData(), 'nuit' => '987654321']);
|
||||
$input->setContainer($this->app)->setRedirector($this->app['redirect']);
|
||||
$input->setUserResolver(fn () => $this->buyer);
|
||||
$input->validateResolved();
|
||||
DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => PurchasingRole::Requester->value]);
|
||||
$before = [Supplier::query()->count(), AuditLog::query()->count()];
|
||||
expect(fn () => app(SupplierController::class)->store($input, app(RequisitionWorkflow::class)))
|
||||
->toThrow(HttpException::class);
|
||||
expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,15 @@ test('activation does not grant permissions and source queries remain tenant sco
|
||||
$this->actingAs($foreign);
|
||||
expect(fn () => app(SupplierPayables::class)->principal($this->f['invoice']))->toThrow(HttpException::class);
|
||||
config(['common-payables.enabled' => false]);
|
||||
expect(SupplierPayables::enabled())->toBeFalse();
|
||||
// Owned PostgreSQL QA fixtures have an explicit environment override; normal
|
||||
// installations still require the configured activation flag.
|
||||
$environment = app()->environment();
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
try {
|
||||
expect(SupplierPayables::enabled())->toBeFalse();
|
||||
} finally {
|
||||
app()->detectEnvironment(fn () => $environment);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shared schema preserves histories and refuses repeated or partial installation', function () {
|
||||
|
||||
@@ -9,12 +9,15 @@ use App\Models\SupplierResponse;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierResponseWorkflow;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
@@ -158,3 +161,86 @@ test('failed audit rolls the response edit back without changing the approved ev
|
||||
expect(fn () => app(SupplierResponseWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $record->id, 'save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Não deve persistir']]))->toThrow(RuntimeException::class);
|
||||
expect($record->refresh()->version)->toBe(1)->and($record->payload['title'])->toBe($this->payload['title'])->and($this->evaluation->refresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
function qualityResponseTasks(object $test, User $actor): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'),
|
||||
fn ($row) => str_starts_with($row['id'], 'supplier-response:')));
|
||||
}
|
||||
|
||||
test('response task follows preparation review return and decision preserving approved source', function () {
|
||||
$before = $this->evaluation->refresh()->getAttributes();
|
||||
$record = responseDraft($this);
|
||||
expect(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1)->and(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->actingAs($this->f['issuer'])->post($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertRedirect();
|
||||
expect(qualityResponseTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$tasks = qualityResponseTasks($this, $this->reviewer);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['href'])->toBe($this->base.'#contraditorio-'.$record->id)->and($tasks[0]['due_on'])->toBeNull();
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', true));
|
||||
$this->post($this->url.'/'.$record->id.'/return', ['version' => 2, 'reason' => 'Completar a referência da origem dos argumentos.'])->assertRedirect();
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toHaveCount(1);
|
||||
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 3])->assertRedirect();
|
||||
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/decide', ['version' => 4, ...$this->decision])->assertRedirect();
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([])
|
||||
->and($this->evaluation->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('response task excludes every contributor and does not disclose arguments or evidence', function () {
|
||||
$record = responseDraft($this);
|
||||
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/save', ['version' => 1, 'payload' => [...$this->payload, 'statement' => 'Argumentos actualizados por outro preparador.']])->assertRedirect();
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1);
|
||||
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 2])->assertRedirect();
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toBe([])->and(qualityResponseTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$tasks = qualityResponseTasks($this, $independent);
|
||||
expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse()->and(array_key_exists('decision', $tasks[0]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('response task excludes foreign company department current conflicts and maintenance', function () {
|
||||
$record = responseInReview($this);
|
||||
$other = receiptFixture();
|
||||
expect(qualityResponseTasks($this, $other['issuer']))->toBe([]);
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
expect(qualityResponseTasks($this, $head))->toBe([]);
|
||||
supplyGrant($this->reviewer, ['finance']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->post($this->url.'/'.$record->id.'/decide', ['version' => 2, ...$this->decision])->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => false]);
|
||||
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id,
|
||||
'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício nos argumentos do fornecedor.']);
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toBe([]);
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.records.0.canReview', false));
|
||||
});
|
||||
|
||||
test('response task keeps old pending records beyond one hundred and pages them without writes', function () {
|
||||
$record = responseInReview($this);
|
||||
for ($i = 0; $i < 104; $i++) {
|
||||
$copy = $record->replicate();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->save();
|
||||
}
|
||||
$before = AuditLog::query()->count();
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(105);
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=CTR-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-response:'.$record->id.':review:2'));
|
||||
$this->get('/requisicoes/tarefas?module=purchases&search=CTR-&page=5')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 5));
|
||||
expect(AuditLog::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('response task and commands reject a revoked role even with a previously loaded actor', function () {
|
||||
$record = responseInReview($this);
|
||||
expect(qualityResponseTasks($this, $this->reviewer))->toHaveCount(1);
|
||||
DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']);
|
||||
expect(qualityResponseTasks($this, $this->reviewer->fresh()))->toBe([]);
|
||||
$this->actingAs($this->reviewer);
|
||||
try {
|
||||
app(SupplierResponseWorkflow::class)->execute($this->reviewer, $this->evaluation->id, $record->id, 'decide', ['version' => 2, ...$this->decision]);
|
||||
$this->fail('O papel revogado não pode decidir.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(403);
|
||||
}
|
||||
expect($record->refresh()->status)->toBe('review')->and($record->version)->toBe(2);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
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';
|
||||
@@ -23,6 +28,139 @@ function reserveData(array $f, string $reference = 'NEC-1', int $quantity = 3000
|
||||
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'quantity_milli' => $quantity, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => $reference];
|
||||
}
|
||||
|
||||
test('task deadline followup changes with time and native cancellation without new decisions or stock writes', function () {
|
||||
$this->freezeTime();
|
||||
$service = app(SupplyCoordination::class);
|
||||
$record = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'PRAZO-NATIVO')));
|
||||
$counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()];
|
||||
$this->get('/requisicoes/tarefas?module=supply&due=today')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)->where('tasks.data.0.due_status', 'today'));
|
||||
$this->travel(1)->days();
|
||||
$this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', '/compras/fornecimento?view=reservations&record_id='.$record->id.'#supply-record-'.$record->id));
|
||||
expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($counts);
|
||||
$service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $record));
|
||||
$this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 0)->where('deadlineCounts.overdue', 0));
|
||||
expect($record->fresh()->state)->toBe('active')->and(DB::table('stock_movements')->count())->toBe($counts[2]);
|
||||
});
|
||||
|
||||
function coordinationCsvRows(string $csv): array
|
||||
{
|
||||
$stream = fopen('php://temp', 'w+');
|
||||
fwrite($stream, $csv);
|
||||
rewind($stream);
|
||||
$rows = [];
|
||||
while (($row = fgetcsv($stream, null, ';', '"', '')) !== false) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
fclose($stream);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
test('coordination export includes all filtered latest records across pages and preserves exact focus', function () {
|
||||
$service = app(SupplyCoordination::class);
|
||||
$old = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'EXPORT-OLD')));
|
||||
for ($i = 0; $i < 26; $i++) {
|
||||
$service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'EXPORT-NEW-'.$i)));
|
||||
}
|
||||
$cancelled = $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $old));
|
||||
$counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()];
|
||||
$response = $this->get('/compras/fornecimento.csv?view=reservations&record_search=export&records_page=2')->assertOk()
|
||||
->assertHeader('Content-Type', 'text/csv; charset=UTF-8')->assertHeader('Cache-Control', 'max-age=0, no-store, private');
|
||||
$rows = coordinationCsvRows($response->streamedContent());
|
||||
expect($rows)->toHaveCount(30);
|
||||
$matches = array_values(array_filter(array_slice($rows, 3), fn ($row) => $row[0] === $old->record_key));
|
||||
expect($matches)->toHaveCount(1)->and($matches[0][1])->toBe('2')->and($matches[0][2])->toBe('cancelled')
|
||||
->and($matches[0][15])->toContain('record_id='.$cancelled->id);
|
||||
$active = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations&record_state=active')->assertOk()->streamedContent());
|
||||
expect($active)->toHaveCount(29);
|
||||
$focused = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations&record_search=NO-MATCH&record_state=active&record_id='.$old->id)->assertOk()->streamedContent());
|
||||
expect($focused)->toHaveCount(4)->and($focused[3][0])->toBe($old->record_key)->and($focused[3][2])->toBe('cancelled');
|
||||
expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($counts);
|
||||
});
|
||||
|
||||
test('coordination export reuses native partial fulfilment and protects formula-like references', function () {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
|
||||
$reference = "\t=HYPERLINK(\"synthetic\")";
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, $reference)));
|
||||
$issue = coordinationIssue($this, '20.000');
|
||||
app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $record));
|
||||
$counts = [DB::table('supply_records')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
$rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations')->assertOk()->streamedContent());
|
||||
expect($rows[3][6])->toStartWith("'")->and($rows[3][8])->toBe('30')->and($rows[3][11])->toBe('10');
|
||||
expect([DB::table('supply_records')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts);
|
||||
});
|
||||
|
||||
test('coordination export validates scope and uses existing read permissions without grants', function () {
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$this->getJson('/compras/fornecimento.csv?view=reservations&record_state=approved')->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento.csv?view=reservations&record_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento.csv?view=alternatives&record_id='.$record->id)->assertUnprocessable();
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_read']);
|
||||
$this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento.csv?view=reservations&record_id='.$record->id)->assertUnprocessable();
|
||||
$rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=reservations')->assertOk()->streamedContent());
|
||||
expect($rows)->toHaveCount(3);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento.csv?view=reservations')->assertForbidden();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]);
|
||||
$this->get('/compras/fornecimento.csv?view=reservations')->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => false, 'expires_at' => now()->subMinute()]);
|
||||
$this->get('/compras/fornecimento.csv?view=reservations')->assertForbidden();
|
||||
});
|
||||
|
||||
test('coordination export keeps delivery and technical alternative meanings distinct', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$delivery = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f)));
|
||||
$rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=deliveries')->assertOk()->streamedContent());
|
||||
expect($rows)->toHaveCount(4)->and($rows[3][0])->toBe($delivery->record_key)->and($rows[3][7])->toBe($this->f['order']->number)->and($rows[3][11])->toBe('100');
|
||||
$alternative = taskAlternative($this);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$rows = coordinationCsvRows($this->get('/compras/fornecimento.csv?view=alternatives')->assertOk()->streamedContent());
|
||||
expect($rows)->toHaveCount(4)->and($rows[3][0])->toBe($alternative->record_key)->and($rows[3][2])->toBe('draft')
|
||||
->and($rows[3][11])->toBe('')->and($rows[3][12])->toBe($alternative->snapshot['substitute']['code']);
|
||||
});
|
||||
|
||||
test('coordination record filters find old sources before pagination and never resurrect previous states', function () {
|
||||
$service = app(SupplyCoordination::class);
|
||||
$old = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'NEC-ANTIGA-LOCALIZAR')));
|
||||
for ($i = 0; $i < 26; $i++) {
|
||||
$service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f, 'NEC-RECENTE-'.$i)));
|
||||
}
|
||||
$cancelled = $service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $old));
|
||||
$counts = [DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()];
|
||||
$this->get('/compras/fornecimento?view=reservations&record_search=nec-antiga')->assertInertia(fn (Assert $p) => $p
|
||||
->where('recordPagination.total', 1)->has('records', 1)->where('records.0.id', $cancelled->id)
|
||||
->where('recordFilters.record_search', 'nec-antiga')->has('history.'.$old->record_key, 2));
|
||||
$this->get('/compras/fornecimento?view=reservations&record_search=nec-antiga&record_state=active')->assertInertia(fn (Assert $p) => $p->where('recordPagination.total', 0)->has('records', 0));
|
||||
$this->get('/compras/fornecimento?view=reservations&record_state=active')->assertInertia(fn (Assert $p) => $p
|
||||
->where('recordPagination.total', 26)->has('records', 25)->where('recordPagination.next_page_url', fn ($url) => str_contains($url, 'record_state=active')));
|
||||
$this->get('/compras/fornecimento?view=reservations&record_state=active&record_search=NO-MATCH&record_id='.$old->id)->assertInertia(fn (Assert $p) => $p
|
||||
->where('recordPagination', null)->where('focusChanged', true)->where('records.0.id', $cancelled->id));
|
||||
foreach ([$this->f['stock']->code, $this->f['stock']->name, $this->f['warehouse']->name] as $search) {
|
||||
$this->get('/compras/fornecimento?view=reservations&record_search='.urlencode(mb_strtolower($search)))->assertInertia(fn (Assert $p) => $p->where('recordPagination.total', 27));
|
||||
}
|
||||
$this->get('/compras/fornecimento?view=reservations&record_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('records', 0));
|
||||
expect([DB::table('supply_records')->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($counts);
|
||||
expect($old->fresh()->state)->toBe('active');
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_read']);
|
||||
$this->actingAs($foreign['receiver'])->get('/compras/fornecimento?view=reservations&record_search=nec-antiga')->assertInertia(fn (Assert $p) => $p->has('records', 0)->where('canPrepare', false));
|
||||
});
|
||||
|
||||
test('coordination record filters enforce per-process states and bounded queries', function (string $view, string $state) {
|
||||
$this->getJson('/compras/fornecimento?view='.$view.'&record_state='.$state)->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento?view='.$view.'&record_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
})->with([['reservations', 'approved'], ['deliveries', 'fulfilled'], ['alternatives', 'active']]);
|
||||
|
||||
test('coordination record filters reuse issued order number without receiving its goods', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f)));
|
||||
$counts = [DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('supply_records')->count()];
|
||||
$this->get('/compras/fornecimento?view=deliveries&record_search='.urlencode(mb_strtolower($this->f['order']->number)))->assertInertia(fn (Assert $p) => $p->has('records', 1)->where('records.0.id', $record->id));
|
||||
expect([DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('supply_records')->count()])->toBe($counts);
|
||||
});
|
||||
|
||||
function deliveryData(array $f): array
|
||||
{
|
||||
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purchase_order_item_id' => $f['line']->id, 'quantity_milli' => 100000,
|
||||
@@ -35,6 +173,101 @@ function coordinationIssue($test, string $quantity): object
|
||||
'responsible_id' => $test->f['receiver']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'reason' => 'Satisfação da necessidade no ensaio.']);
|
||||
}
|
||||
|
||||
function coordinationSourceRequest(object $test, string $number, string $status = 'approved'): array
|
||||
{
|
||||
$request = $test->f['r']->replicate();
|
||||
$request->number = $number;
|
||||
$request->status = $status;
|
||||
$request->save();
|
||||
$line = $test->f['r']->items()->firstOrFail()->replicate();
|
||||
$line->requisition_id = $request->id;
|
||||
$line->catalogue_snapshot = ['stock_item_id' => $test->f['stock']->id];
|
||||
$line->save();
|
||||
|
||||
return [$request, $line];
|
||||
}
|
||||
|
||||
test('supply line search reaches an older approved request and conserves its selected canonical line', function () {
|
||||
[$old, $line] = coordinationSourceRequest($this, 'REQ-LOOKUP-OLD');
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
coordinationSourceRequest($this, 'REQ-LOOKUP-NEW-'.$i);
|
||||
}
|
||||
$before = [$old->fresh()->getAttributes(), $line->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()];
|
||||
$this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $p) => $p->has('requestLines', 100)
|
||||
->where('requestLines', fn ($rows) => collect($rows)->where('id', $line->id)->isEmpty()));
|
||||
$this->get('/compras/fornecimento?view=reservations&line_search=req-lookup-old')->assertInertia(fn (Assert $p) => $p->has('requestLines', 1)->where('requestLines.0.id', $line->id));
|
||||
$this->get('/compras/fornecimento?view=reservations&line_search=NO-MATCH-FICTICIO&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->has('requestLines', 1));
|
||||
expect([$old->fresh()->getAttributes(), $line->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($before);
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'requisition_item_id' => $line->id]));
|
||||
expect($record->data['reference'])->toBe($old->number.' / '.$line->id)->and(DB::table('stock_movements')->count())->toBe($before[3]);
|
||||
});
|
||||
|
||||
test('supply line search reuses issued order lines without issuing another order or receiving stock', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$before = [DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
$this->get('/compras/fornecimento?view=deliveries&line_search='.urlencode(mb_strtolower($this->f['order']->number)))->assertInertia(fn (Assert $p) => $p->has('orderLines', 1)->where('orderLines.0.id', $this->f['line']->id));
|
||||
$this->get('/compras/fornecimento?view=deliveries&line_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $p) => $p->has('orderLines', 0));
|
||||
$this->get('/compras/fornecimento?view=deliveries&line_search=NO-MATCH-FICTICIO&selected_order_line_id='.$this->f['line']->id)->assertInertia(fn (Assert $p) => $p->has('orderLines', 1));
|
||||
expect([DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f)));
|
||||
expect($record->data['purchase_order_item_id'])->toBe($this->f['line']->id)
|
||||
->and([DB::table('purchase_orders')->count(), DB::table('goods_receipts')->count(), DB::table('stock_movements')->count()])->toBe(array_slice($before, 0, 3));
|
||||
});
|
||||
|
||||
test('supply line search rejects invalid input foreign lines and unapproved request sources', function () {
|
||||
[$draft, $line] = coordinationSourceRequest($this, 'REQ-LOOKUP-DRAFT', 'draft');
|
||||
$this->get('/compras/fornecimento?view=reservations&line_search=REQ-LOOKUP-DRAFT&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->has('requestLines', 0));
|
||||
$this->getJson('/compras/fornecimento?view=reservations&line_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=reservations&line_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('requestLines', 0));
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_read']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento?view=reservations&selected_request_line_id='.$line->id)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('requestLines', 0));
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_reserve', 'supply_delivery']);
|
||||
$this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento?view=reservations&selected_request_line_id='.$line->id)->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento?view=deliveries&selected_order_line_id='.$this->f['line']->id)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('reservation source search reaches an old issue and preserves selection without repeating its movement', function () {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
|
||||
$old = coordinationIssue($this, '0.001');
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
coordinationIssue($this, '0.001');
|
||||
}
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$url = '/compras/fornecimento?view=reservations&issue_record_id='.$record->id;
|
||||
$before = [DB::table('stock_movements')->count(), DB::table('audit_logs')->count(), $old->fresh()->getAttributes()];
|
||||
$this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $page) => $page->has('operations', 100)
|
||||
->where('operations', fn ($rows) => collect($rows)->where('id', $old->id)->isEmpty()));
|
||||
$this->get($url.'&issue_search='.urlencode(mb_strtolower($old->reference)))->assertInertia(fn (Assert $page) => $page->has('operations', 1)->where('operations.0.id', $old->id));
|
||||
$this->get($url.'&issue_search=NO-MATCH-FICTICIO&selected_issue_id='.$old->id)->assertInertia(fn (Assert $page) => $page->has('operations', 1)->where('operations.0.id', $old->id));
|
||||
expect([DB::table('stock_movements')->count(), DB::table('audit_logs')->count(), $old->fresh()->getAttributes()])->toBe($before);
|
||||
app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $old->id, 'quantity_milli' => 1], 'fulfil', $record));
|
||||
expect(DB::table('stock_movements')->count())->toBe($before[0]);
|
||||
});
|
||||
|
||||
test('reservation source search respects article warehouse current state permissions and company', function () {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
|
||||
$issue = coordinationIssue($this, '1.000');
|
||||
$service = app(SupplyCoordination::class);
|
||||
$item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'OTHER-SEARCH', 'name' => 'Outro artigo de teste', 'unit' => 'cx']);
|
||||
$other = $service->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'stock_item_id' => $item->id]));
|
||||
$this->get('/compras/fornecimento?view=reservations&issue_record_id='.$other->id.'&selected_issue_id='.$issue->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0));
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'OTHER-SEARCH', 'name' => 'Outro armazém de teste']);
|
||||
$otherPlace = $service->save($this->f['receiver'], 'reservation', supplyInput([...reserveData($this->f), 'warehouse_id' => $warehouse->id]));
|
||||
$this->get('/compras/fornecimento?view=reservations&issue_record_id='.$otherPlace->id.'&selected_issue_id='.$issue->id)->assertInertia(fn (Assert $page) => $page->has('operations', 0));
|
||||
$record = $service->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$url = '/compras/fornecimento?view=reservations&issue_record_id='.$record->id;
|
||||
$this->getJson($url.'&issue_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_read']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get($url)->assertInertia(fn (Assert $page) => $page->where('canPrepare', false)->has('operations', 0));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$service->save($this->f['receiver'], 'reservation', supplyInput([], 'cancel', $record));
|
||||
$this->get($url)->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_reserve']);
|
||||
$this->actingAs($foreign['receiver'])->getJson($url)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('reservations enter calendar once and their allocations reuse existing issues without changing stock', function () {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
|
||||
$input = supplyInput(reserveData($this->f));
|
||||
@@ -129,3 +362,152 @@ test('coordination permission never grants unrelated workflow commands or crosse
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/reservation', supplyInput(reserveData($foreign)))->assertNotFound();
|
||||
$this->get('/compras/fornecimento?view=reservations')->assertOk();
|
||||
});
|
||||
function taskAlternative($test, string $compatibility = 'equivalent'): SupplyRecord
|
||||
{
|
||||
$substitute = StockItem::query()->create(['tenant_id' => $test->f['tenant']->id, 'created_by' => $test->f['issuer']->id, 'code' => 'TASK-SUB', 'name' => 'Alternativa sintética', 'unit' => 'cx']);
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(SupplyCoordination::class)->save($test->f['issuer'], 'alternative', supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'substitute_id' => $substitute->id, 'application' => 'Aplicação técnica de ensaio', 'compatibility' => $compatibility, 'evidence' => 'Evidência técnica sintética para validar encaminhamento.',
|
||||
'factor_numerator' => 1, 'factor_denominator' => 1, 'valid_until' => today()->addMonth()->toDateString()]));
|
||||
}
|
||||
|
||||
test('alternative task reaches the independent technical reviewer and a stale link shows the current decision', function () {
|
||||
$draft = taskAlternative($this);
|
||||
supplyGrant($this->f['issuer'], ['supply_technical_review']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'alternative_review')
|
||||
->where('tasks.data.0.href', '/compras/fornecimento?view=alternatives&record_id='.$draft->id.'#supply-record-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
||||
$counts = [SupplyRecord::query()->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
$this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true));
|
||||
expect([SupplyRecord::query()->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts);
|
||||
$approved = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([], 'approve', $draft));
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/compras/fornecimento?view=alternatives&record_id='.$draft->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)->where('records.0.canSuspend', true));
|
||||
});
|
||||
|
||||
test('alternative task preserves source checks incomplete analysis and independence of all preparers', function () {
|
||||
$draft = taskAlternative($this, 'pending');
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$this->f['stock']->update(['name' => 'Nova especificação do ensaio']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$returned = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([], 'return', $draft));
|
||||
expect($returned->snapshot['item'])->toBe($draft->snapshot['item']);
|
||||
supplyGrant($this->f['receiver'], ['supply_technical_prepare']);
|
||||
$updated = app(SupplyCoordination::class)->save($this->f['receiver'], 'alternative', supplyInput([...$draft->data, 'compatibility' => 'equivalent'], 'save', $returned));
|
||||
supplyGrant($this->f['issuer'], ['supply_technical_review']);
|
||||
foreach ([$this->f['issuer'], $this->f['receiver']] as $user) {
|
||||
expect(app(SupplyCoordination::class)->canReviewAlternative($user, $updated))->toBeFalse();
|
||||
}
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_review']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_technical_review']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
});
|
||||
|
||||
test('alternative task keeps company type maintenance and grant expiry boundaries', function () {
|
||||
$draft = taskAlternative($this);
|
||||
$path = '/compras/fornecimento?view=alternatives&record_id='.$draft->id;
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_technical_review']);
|
||||
$this->actingAs($foreign['receiver'])->getJson($path)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=calendar&record_id='.$draft->id)->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(SupplyCoordination::class)->canReviewAlternative($this->f['receiver'], $draft))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
|
||||
});
|
||||
|
||||
function coordinationTasks(object $test, User $actor): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'coordination:')));
|
||||
}
|
||||
|
||||
test('coordination task follows planned reservation partial actual issues and completion without duplicating stock', function () {
|
||||
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$tasks = coordinationTasks($this, $this->f['receiver']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['due_on'])->toBe(today()->toDateString())->and($tasks[0]['task'])->toBe('reservation_followup');
|
||||
expect(coordinationTasks($this, $this->f['issuer']))->toBe([]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$issue = coordinationIssue($this, '20.000');
|
||||
$partial = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $record));
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toHaveCount(1)
|
||||
->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $record, 'fulfil'))->toBeFalse();
|
||||
$this->get('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $partial->id)->where('records.0.canFulfil', true));
|
||||
$second = coordinationIssue($this, '10.000');
|
||||
$fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $second->id, 'quantity_milli' => 10000], 'fulfil', $partial));
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toBe([])
|
||||
->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $fulfilled, 'fulfil'))->toBeFalse()
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(70000)
|
||||
->and($record->fresh()->state)->toBe('active');
|
||||
});
|
||||
|
||||
test('coordination task follows confirmed delivery partial and full native receipts', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$record = app(SupplyCoordination::class)->save($this->f['issuer'], 'delivery', supplyInput(deliveryData($this->f)));
|
||||
$tasks = coordinationTasks($this, $this->f['issuer']);
|
||||
expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('delivery_followup')->and($tasks[0]['due_on'])->toBe(today()->addWeek()->toDateString());
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toBe([]);
|
||||
foreach (['20.000', '80.000'] as $quantity) {
|
||||
$receipt = receiptPayload($this->f, $quantity);
|
||||
unset($receipt['items'][0]['stock_item_id']);
|
||||
$this->actingAs($this->f['receiver'])->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', $receipt)->assertRedirect();
|
||||
expect(coordinationTasks($this, $this->f['issuer']))->toHaveCount($quantity === '20.000' ? 1 : 0);
|
||||
}
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(100000)
|
||||
->and($record->fresh()->state)->toBe('active');
|
||||
});
|
||||
|
||||
test('coordination task cancellation removes the pending task and preserves the original forecast', function () {
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$before = $record->fresh()->getAttributes();
|
||||
$input = supplyInput([], 'cancel', $record);
|
||||
$cancelled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', $input);
|
||||
expect(app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', $input)->id)->toBe($cancelled->id)
|
||||
->and(coordinationTasks($this, $this->f['receiver']))->toBe([])
|
||||
->and($record->fresh()->getAttributes())->toBe($before)
|
||||
->and(app(SupplyCoordination::class)->canManage($this->f['receiver'], $cancelled, 'cancel'))->toBeFalse()
|
||||
->and(DB::table('stock_movements')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('coordination task respects company maintenance role expiry and focus type boundaries', function () {
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['receiver'], ['supply_reserve']);
|
||||
expect(coordinationTasks($this, $foreign['receiver']))->toBe([]);
|
||||
$this->getJson('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=deliveries&record_id='.$record->id)->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toBe([]);
|
||||
$this->postJson('/compras/fornecimento/reservation', supplyInput([], 'cancel', $record))->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toBe([]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
|
||||
expect($record->fresh()->state)->toBe('active');
|
||||
});
|
||||
|
||||
test('coordination task and general pagination retain old pending needs beyond one hundred', function () {
|
||||
$record = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(reserveData($this->f)));
|
||||
for ($i = 0; $i < 104; $i++) {
|
||||
$copy = $record->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->data = [...$copy->data, 'reference' => 'NEC-OLD-'.$i];
|
||||
$copy->created_at = $record->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$before = DB::table('audit_logs')->count();
|
||||
expect(coordinationTasks($this, $this->f['receiver']))->toHaveCount(105);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'coordination:'.$record->id.':followup'));
|
||||
$this->get('/compras/fornecimento?view=reservations')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 105)->has('records', 25));
|
||||
$this->get('/compras/fornecimento?view=reservations&records_page=5')->assertInertia(fn (Assert $page) => $page->has('records', 5)->where('records.4.id', $record->id));
|
||||
$this->get('/compras/fornecimento?view=reservations&record_id='.$record->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $record->id)->where('recordPagination', null));
|
||||
expect(DB::table('audit_logs')->count())->toBe($before)->and(DB::table('stock_movements')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
@@ -10,6 +12,7 @@ use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
@@ -63,6 +66,66 @@ function historyPolicy($test, SupplyRecord $history, array $extra = []): SupplyR
|
||||
return app(SupplyPlanning::class)->latest('policy')->sole();
|
||||
}
|
||||
|
||||
test('history source search finds an older approved period before the recent choice limit', function () {
|
||||
$old = approvedHistory($this);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput([...historyData($this->f), 'period_from' => '2026-09-01', 'period_until' => '2026-09-30']));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$recent = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(['confirm_scope' => true], 'approve', $draft));
|
||||
for ($i = 0; $i < 99; $i++) {
|
||||
$copy = $recent->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->created_at = $recent->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$query = '/compras/fornecimento?view=calendar&history_stock_item_id='.$this->f['stock']->id.'&history_warehouse_id='.$this->f['warehouse']->id.'&history_purpose=internal';
|
||||
$before = [$old->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()];
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $p) => $p->has('histories', 100)->where('histories', fn ($rows) => collect($rows)->where('key', $old->record_key)->isEmpty()));
|
||||
$this->get($query.'&history_period_from=2026-10-01&history_period_until=2026-10-30')->assertInertia(fn (Assert $p) => $p->has('histories', 1)->where('histories.0.key', $old->record_key));
|
||||
$this->get($query.'&history_period_until=2026-09-30&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 101));
|
||||
$this->get(str_replace('history_purpose=internal', 'history_purpose=resale', $query).'&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 0));
|
||||
expect([$old->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count()])->toBe($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'suspend', $old));
|
||||
$this->actingAs($this->f['issuer'])->get($query.'&history_period_from=2026-10-01&selected_history_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p->has('histories', 0));
|
||||
});
|
||||
|
||||
test('history source search validates scope dates and preparation rights', function () {
|
||||
$old = approvedHistory($this);
|
||||
$query = '/compras/fornecimento?view=calendar&history_stock_item_id='.$this->f['stock']->id.'&history_warehouse_id='.$this->f['warehouse']->id.'&history_purpose=internal';
|
||||
$this->actingAs($this->f['issuer'])->getJson($query.'&history_period_from=2026-10-30&history_period_until=2026-10-01')->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento?view=calendar&history_period_from=2026-10-01')->assertUnprocessable();
|
||||
$tenant = Tenant::query()->create(['name' => 'Histórico isolado']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['supply_prepare']);
|
||||
$this->actingAs($foreign)->getJson($query.'&selected_history_key='.$old->record_key)->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->get($query)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('histories', 0));
|
||||
});
|
||||
|
||||
test('supply pagination preserves an approved historical source already selected outside the recent choices', function () {
|
||||
$history = approvedHistory($this);
|
||||
$policy = historyPolicy($this, $history);
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$copy = $history->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->created_at = $history->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$before = [$policy->fresh()->getAttributes(), $history->fresh()->getAttributes(), DB::table('audit_logs')->count()];
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar&record_id='.$policy->id)->assertInertia(fn (Assert $page) => $page
|
||||
->has('histories', 101)->where('histories', fn ($rows) => collect($rows)->where('key', $history->record_key)->count() === 1)
|
||||
->where('records.0.data.history_key', $history->record_key));
|
||||
expect([$policy->fresh()->getAttributes(), $history->fresh()->getAttributes(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'suspend', $history));
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar&record_id='.$policy->id)->assertInertia(fn (Assert $page) => $page
|
||||
->has('histories', 100)->where('histories', fn ($rows) => collect($rows)->where('key', $history->record_key)->isEmpty())
|
||||
->where('records.0.data.history_key', $history->record_key));
|
||||
expect($policy->fresh()->getAttributes())->toBe($before[0])->and($history->fresh()->getAttributes())->toBe($before[1]);
|
||||
});
|
||||
|
||||
test('historical mean includes all calendar days and policy reuses the approved period without accepting forged totals', function () {
|
||||
$history = approvedHistory($this, ['total_milli' => 999999, 'daily_mean_milli' => '999']);
|
||||
expect($history->snapshot['analysis']['total_milli'])->toBe(300000)->and($history->snapshot['analysis']['days'])->toBe(30)
|
||||
@@ -185,3 +248,76 @@ test('replacement policy can reuse all approved parameters by reference while ac
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertRedirect();
|
||||
expect(app(SupplyPlanning::class)->latest('policy')->where('state', 'approved')->count())->toBe(1)->and($source->fresh()->data['target_milli'])->toBe(900000);
|
||||
});
|
||||
test('history task routes the prepared analysis to an independent reviewer without changing scope or ledger', function () {
|
||||
$draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
supplyGrant($this->f['issuer'], ['supply_history_review']);
|
||||
$path = '/compras/fornecimento?view=history&record_id='.$draft->id;
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $path.'#supply-record-'.$draft->id)->where('tasks.data.0.due_on', null)->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count()];
|
||||
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)
|
||||
->where('focusRecordId', $draft->id)->where('focusChanged', false)->where('records.0.canApprove', true)->where('records.0.canReturn', true));
|
||||
expect([SupplyRecord::query()->count(), StockMovement::query()->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => false], 'approve', $draft))->assertUnprocessable();
|
||||
$approved = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(['confirm_scope' => true], 'approve', $draft));
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)
|
||||
->where('records.0.canApprove', false)->where('records.0.canSuspend', true));
|
||||
});
|
||||
|
||||
test('history task excludes every previous preparer and uses the specific historical competence', function () {
|
||||
$draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
supplyGrant($this->f['receiver'], ['supply_history_prepare']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$updated = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput(historyData($this->f), 'save', $draft));
|
||||
supplyGrant($this->f['issuer'], ['supply_history_review']);
|
||||
foreach ([$this->f['issuer'], $this->f['receiver']] as $user) {
|
||||
$this->actingAs($user)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(SupplyHistory::class)->canReview($user, $updated))->toBeFalse();
|
||||
}
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_review']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get('/compras/fornecimento?view=history')->assertForbidden();
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_history_review']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'history_review'));
|
||||
expect(app(SupplyHistory::class)->canReview($this->f['otherReceiver'], $draft))->toBeFalse()
|
||||
->and(app(SupplyHistory::class)->canReview($this->f['otherReceiver'], $updated))->toBeTrue();
|
||||
});
|
||||
|
||||
test('history task keeps changed sources available for return and never approves stale analysis silently', function () {
|
||||
$draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
$this->f['stock']->update(['name' => 'Nome alterado depois da análise']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertUnprocessable();
|
||||
$returned = app(SupplyHistory::class)->save($this->f['receiver'], supplyInput([], 'return', $draft));
|
||||
expect($returned->state)->toBe('returned')->and($returned->snapshot['item'])->toBe($draft->snapshot['item']);
|
||||
});
|
||||
|
||||
test('history task preserves old pending analyses beyond the display cap and all task pages', function () {
|
||||
$first = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supply:'.$first->id.':review'));
|
||||
$this->get('/requisicoes/tarefas?module=supply&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get('/compras/fornecimento?view=history')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 105));
|
||||
$this->get('/compras/fornecimento?view=history&record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id));
|
||||
});
|
||||
|
||||
test('history task preserves company isolation document type maintenance and grant expiry', function () {
|
||||
$draft = app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(historyData($this->f)));
|
||||
$path = '/compras/fornecimento?view=history&record_id='.$draft->id;
|
||||
$tenant = Tenant::query()->create(['name' => 'Outra empresa de ensaio']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['supply_history_review']);
|
||||
$this->actingAs($foreign)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->getJson($path)->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=calendar&record_id='.$draft->id)->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($path)->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\SupplySuggestions;
|
||||
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();
|
||||
supplyGrant($this->f['issuer'], ['supply_read', 'supply_history_prepare', 'import_prepare', 'import_read']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
});
|
||||
|
||||
test('agreement source search finds old approved sources and preserves selection without reviving suspension', function () {
|
||||
$old = $this->f['agreement']->replicate();
|
||||
$old->record_key = (string) Str::uuid();
|
||||
$old->operation_key = (string) Str::uuid();
|
||||
$old->created_at = now();
|
||||
$old->data = [...$old->data, 'reference' => 'ACORDO-ANTIGO-PESQUISA'];
|
||||
$old->save();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$copy = $old->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->data = [...$copy->data, 'reference' => 'RECENTE-'.$i];
|
||||
$copy->created_at = now();
|
||||
$copy->save();
|
||||
}
|
||||
$base = '/compras/fornecimento?view=calendar&agreement_stock_item_id='.$this->f['stock']->id.'&agreement_warehouse_id='.$this->f['warehouse']->id;
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()];
|
||||
$this->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $p) => $p->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->isEmpty()));
|
||||
$this->get($base.'&agreement_search=acordo-antigo')->assertInertia(fn (Assert $p) => $p
|
||||
->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->count() === 1));
|
||||
$this->get($base.'&agreement_search=NO-MATCH&selected_agreement_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p
|
||||
->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->count() === 1));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()])->toBe($before);
|
||||
$policy = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'agreement_key' => $old->record_key]));
|
||||
expect($policy->data['agreement_version'])->toBe($old->version)->and($policy->data['lead_days'])->toBe($old->data['lead_days']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'agreement', supplyInput([], 'suspend', $old));
|
||||
$this->actingAs($this->f['issuer'])->get($base.'&selected_agreement_key='.$old->record_key)->assertInertia(fn (Assert $p) => $p
|
||||
->where('agreements', fn ($rows) => collect($rows)->where('record_key', $old->record_key)->isEmpty()));
|
||||
expect($old->fresh()->state)->toBe('approved');
|
||||
});
|
||||
|
||||
test('agreement source search validates company and scope and leaves readers without preparation choices', function () {
|
||||
$base = '/compras/fornecimento?view=calendar';
|
||||
$this->getJson($base.'&agreement_search=TEST')->assertUnprocessable();
|
||||
$this->getJson($base.'&agreement_stock_item_id='.$this->f['stock']->id)->assertUnprocessable();
|
||||
$scope = '&agreement_stock_item_id='.$this->f['stock']->id.'&agreement_warehouse_id='.$this->f['warehouse']->id;
|
||||
$this->getJson($base.$scope.'&agreement_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$otherItem = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'OTHER-AGREEMENT', 'name' => 'Outro artigo do ensaio', 'unit' => 'cx']);
|
||||
$this->get($base.'&agreement_stock_item_id='.$otherItem->id.'&agreement_warehouse_id='.$this->f['warehouse']->id.'&selected_agreement_key='.$this->f['agreement']->record_key)
|
||||
->assertInertia(fn (Assert $p) => $p->has('agreements', 0));
|
||||
$foreign = suggestionFixture();
|
||||
$this->actingAs($this->f['issuer'])->getJson($base.$scope.'&selected_agreement_key='.$foreign['agreement']->record_key)->assertUnprocessable();
|
||||
$this->get($base.$scope.'&agreement_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('agreements', 1));
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_read']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get($base.$scope)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('agreements', 0));
|
||||
});
|
||||
|
||||
test('supply pagination retains old records beyond one hundred and preserves direct focus without writes', function (string $view) {
|
||||
$first = match ($view) {
|
||||
'agreements' => $this->f['agreement'],
|
||||
'calendar' => $this->f['policy'],
|
||||
'suggestions' => app(SupplySuggestions::class)->save($this->f['issuer'], supplyInput(['policy_key' => $this->f['policy']->record_key])),
|
||||
'history' => app(SupplyHistory::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id,
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'purpose' => 'internal', 'period_from' => today()->subDays(30)->toDateString(),
|
||||
'period_until' => today()->subDay()->toDateString(), 'quality' => 'complete', 'pattern' => 'regular'])),
|
||||
};
|
||||
for ($i = 0; $i < 104; $i++) {
|
||||
$copy = $first->replicate();
|
||||
$copy->record_key = (string) Str::uuid();
|
||||
$copy->operation_key = (string) Str::uuid();
|
||||
$copy->state = 'draft';
|
||||
$copy->version = 1;
|
||||
$copy->created_at = $first->created_at;
|
||||
$copy->save();
|
||||
}
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count(), $first->fresh()->getAttributes()];
|
||||
$this->get('/compras/fornecimento?view='.$view)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 105)->has('records', 25));
|
||||
$this->get('/compras/fornecimento?view='.$view.'&records_page=5')->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page
|
||||
->where('recordPagination.current_page', 5)->has('records', 5)->where('records.4.id', $first->id)
|
||||
->where('recordPagination.prev_page_url', fn ($url) => str_contains($url, 'view='.$view)));
|
||||
$this->get('/compras/fornecimento?view='.$view.'&record_id='.$first->id.'&records_page=5')->assertInertia(fn (Assert $page) => $page->where('recordPagination', null)
|
||||
->has('records', 1)->where('records.0.id', $first->id));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count(), $first->fresh()->getAttributes()])->toBe($before);
|
||||
})->with(['agreements', 'calendar', 'suggestions', 'history']);
|
||||
|
||||
test('supply pagination keeps import milestones in one current record and preserves its historical source', function () {
|
||||
$this->f = receiptFixture();
|
||||
supplyGrant($this->f['issuer'], ['procurement', 'import_prepare']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$service = app(SupplyPlanning::class);
|
||||
$first = $service->save($this->f['issuer'], 'import', supplyInput(['purchase_order_id' => $this->f['order']->id, 'purpose' => 'internal',
|
||||
'jurisdiction' => 'Jurisdição sintética', 'origin_country' => 'País A', 'dispatch_country' => 'País B', 'regime' => 'Regime fictício de teste']));
|
||||
$before = $first->fresh()->getAttributes();
|
||||
$current = $service->save($this->f['issuer'], 'import', supplyInput(['evidence' => 'Documento fictício de trânsito', 'event_date' => today()->toDateString()], 'transit', $first));
|
||||
$this->get('/compras/fornecimento?view=imports')->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 1)->has('records', 1)->where('records.0.id', $current->id));
|
||||
$this->get('/compras/fornecimento?view=imports&record_id='.$first->id)->assertInertia(fn (Assert $page) => $page->where('recordPagination', null)
|
||||
->where('focusChanged', true)->where('records.0.id', $current->id)->has('history.'.$first->record_key, 2));
|
||||
expect($first->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('supply pagination enforces company scope and page validation across the views', function (string $view) {
|
||||
$this->getJson('/compras/fornecimento?view='.$view.'&records_page=0')->assertUnprocessable();
|
||||
$this->getJson('/compras/fornecimento?view='.$view.'&records_page=1000001')->assertUnprocessable();
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa sintética isolada']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['supply_read', 'import_read']);
|
||||
$this->actingAs($foreign)->get('/compras/fornecimento?view='.$view)->assertInertia(fn (Assert $page) => $page->where('recordPagination.total', 0)->has('records', 0));
|
||||
$this->getJson('/compras/fornecimento?view='.$view.'&record_id='.$this->f['agreement']->id)->assertUnprocessable();
|
||||
})->with(['agreements', 'calendar', 'suggestions', 'history', 'imports']);
|
||||
|
||||
test('supply pagination preserves maintenance and membership expiry boundaries', function () {
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->get('/compras/fornecimento?view=agreements')->assertForbidden();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/compras/fornecimento?view=calendar')->assertForbidden();
|
||||
});
|
||||
|
||||
test('supply pagination preserves the approved policy of an older suggestion outside recent choices', function () {
|
||||
$suggestion = app(SupplySuggestions::class)->save($this->f['issuer'], supplyInput(['policy_key' => $this->f['policy']->record_key]));
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
|
||||
'code' => 'POLICY-CHOICE-'.$i, 'name' => 'Artigo sintético de política '.$i, 'unit' => 'cx']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'stock_item_id' => $item->id]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
||||
}
|
||||
$before = [$suggestion->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()];
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&record_id='.$suggestion->id)->assertInertia(fn (Assert $page) => $page
|
||||
->has('policies', 101)->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->count() === 1)
|
||||
->where('records.0.data.policy_key', $this->f['policy']->record_key));
|
||||
expect([$suggestion->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()])->toBe($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&record_id='.$suggestion->id)->assertInertia(fn (Assert $page) => $page
|
||||
->has('policies', 100)->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->isEmpty())
|
||||
->where('records.0.data.policy_key', $this->f['policy']->record_key));
|
||||
expect($suggestion->fresh()->getAttributes())->toBe($before[0]);
|
||||
});
|
||||
|
||||
test('supply pagination import source search preserves selection validates scope and never creates documents', function () {
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()];
|
||||
$number = mb_strtolower($this->f['order']->number);
|
||||
$this->get('/compras/fornecimento?view=imports&order_search='.urlencode($number))->assertInertia(fn (Assert $page) => $page->has('orders', 1)->where('orders.0.id', $this->f['order']->id));
|
||||
$this->get('/compras/fornecimento?view=imports&order_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $page) => $page->has('orders', 0));
|
||||
$this->get('/compras/fornecimento?view=imports&order_search=NO-MATCH-FICTICIO&selected_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->has('orders', 1)->where('orders.0.id', $this->f['order']->id));
|
||||
$this->getJson('/compras/fornecimento?view=imports&order_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=imports&order_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $page) => $page->has('orders', 0));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count(), DB::table('stock_movements')->count()])->toBe($before);
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($this->f['issuer'])->getJson('/compras/fornecimento?view=imports&selected_order_id='.$foreign['order']->id)->assertUnprocessable();
|
||||
supplyGrant($this->f['otherReceiver'], ['import_read']);
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/compras/fornecimento?view=imports&selected_order_id='.$this->f['order']->id)->assertInertia(fn (Assert $page) => $page->where('canPrepare', false)->has('orders', 0));
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
@@ -29,6 +30,36 @@ function approveSuggestion($test, SupplyRecord $draft, array $extra = []): Suppl
|
||||
return app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
}
|
||||
|
||||
test('policy source search finds an older approved policy and keeps only eligible selections', function () {
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$item = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
|
||||
'code' => 'LOOKUP-'.$i, 'name' => 'Outro artigo de teste '.$i, 'unit' => 'cx']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput([...supplyPolicy($this->f), 'stock_item_id' => $item->id]));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
||||
}
|
||||
$before = [$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()];
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $p) => $p->has('policies', 100)
|
||||
->where('policies', fn ($rows) => collect($rows)->where('key', $this->f['policy']->record_key)->isEmpty()));
|
||||
$this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode(mb_strtolower($this->f['stock']->code)))->assertInertia(fn (Assert $p) => $p->has('policies', 1)->where('policies.0.key', $this->f['policy']->record_key));
|
||||
$this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO')->assertInertia(fn (Assert $p) => $p->has('policies', 0));
|
||||
$this->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 1));
|
||||
expect([$this->f['policy']->fresh()->getAttributes(), DB::table('audit_logs')->count(), DB::table('stock_movements')->count(), DB::table('requisitions')->count()])->toBe($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=suggestions&policy_search=NO-MATCH-FICTICIO&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->has('policies', 0));
|
||||
expect($this->f['policy']->fresh()->getAttributes())->toBe($before[0]);
|
||||
});
|
||||
|
||||
test('policy source search validates inputs company scope and preparation rights', function () {
|
||||
$this->getJson('/compras/fornecimento?view=suggestions&policy_search='.str_repeat('A', 101))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=suggestions&policy_search='.urlencode("' OR 1=1 --"))->assertInertia(fn (Assert $p) => $p->has('policies', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->has('policies', 0));
|
||||
$foreign = suggestionFixture();
|
||||
$this->actingAs($foreign['issuer'])->getJson('/compras/fornecimento?view=suggestions&selected_policy_key='.$this->f['policy']->record_key)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('suggestion freezes the forecast catalogue agreement and company defaults without repeating descriptors', function () {
|
||||
$input = supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => null, 'name' => 'FORGED', 'snapshot' => ['balance' => 999]]);
|
||||
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
|
||||
@@ -136,3 +167,69 @@ test('another company cannot resolve a suggestion or convert with another depart
|
||||
$approved = approveSuggestion($this, $draft);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $other['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
||||
});
|
||||
test('suggestion task connects independent review conversion and completed tracking without duplicate drafts', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
$path = '/compras/fornecimento?view=suggestions&record_id='.$draft->id;
|
||||
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.task', 'suggestion_approve')->where('tasks.data.0.href', $path.'#supply-suggestion-'.$draft->id)->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()];
|
||||
$this->get($path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.canApprove', true));
|
||||
expect([SupplyRecord::query()->count(), Requisition::query()->count(), DB::table('audit_logs')->count()])->toBe($before);
|
||||
$approved = approveSuggestion($this, $draft);
|
||||
supplyGrant($this->f['receiver'], ['supply_suggest_convert', 'requester']);
|
||||
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse();
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['issuer'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_convert'));
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.canConvert', true));
|
||||
$command = supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved);
|
||||
$this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/suggestion', $command)->assertRedirect();
|
||||
expect(Requisition::query()->count())->toBe($before[1] + 1);
|
||||
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
$purchase = Requisition::query()->findOrFail($converted->data['requisition_id']);
|
||||
expect($purchase->status)->toBe('draft')->and($purchase->steps()->count())->toBe(0);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$purchase->update(['status' => 'rejected']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.task', 'suggestion_close'));
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertRedirect();
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
});
|
||||
|
||||
test('suggestion task preserves source revalidation specific grants and focus isolation', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
$this->f['stock']->update(['name' => 'Origem alterada depois da preparação']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'return', $draft))->assertRedirect();
|
||||
$returned = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect($returned->snapshot['item'])->toBe($draft->snapshot['item'])->and(app(SupplySuggestions::class)->canAct($this->f['receiver'], $draft, 'approve'))->toBeFalse();
|
||||
$this->getJson('/compras/fornecimento?view=calendar&record_id='.$returned->id)->assertUnprocessable();
|
||||
$foreign = suggestionFixture();
|
||||
$this->actingAs($foreign['receiver'])->getJson('/compras/fornecimento?view=suggestions&record_id='.$returned->id)->assertUnprocessable();
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $returned, 'approve'))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
|
||||
});
|
||||
|
||||
test('suggestion task excludes all preparers and conversion without requester authority', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
supplyGrant($this->f['receiver'], ['supply_suggest_prepare']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$updated = app(SupplySuggestions::class)->save($this->f['receiver'], supplyInput(['policy_key' => $this->f['policy']->record_key], 'save', $draft));
|
||||
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
|
||||
foreach ([$this->f['issuer'], $this->f['receiver']] as $user) {
|
||||
expect(app(SupplySuggestions::class)->canAct($user, $updated, 'approve'))->toBeFalse();
|
||||
}
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_suggest_review']);
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
$approved = app(SupplySuggestions::class)->save($this->f['otherReceiver'], supplyInput([], 'approve', $updated));
|
||||
DB::table('organization_profiles')->whereIn('id', DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->pluck('profile_id'))->update(['permissions' => json_encode(['supply_suggest_convert'])]);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(SupplySuggestions::class)->canAct($this->f['receiver'], $approved, 'convert'))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = suggestionFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
|
||||
$this->data = [...supplyPolicy($this->f), 'purpose' => 'internal', 'agreement_key' => $this->f['agreement']->record_key];
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->draft = app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput($this->data));
|
||||
$this->path = '/compras/fornecimento?view=calendar&record_id='.$this->draft->id;
|
||||
});
|
||||
|
||||
test('supply task policy reaches an independent reviewer and a stale link shows the new state without approving twice', function () {
|
||||
supplyGrant($this->f['issuer'], ['supply_review']);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('records.0.canApprove', false)->where('records.0.canReturn', false));
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)
|
||||
->where('tasks.data.0.href', $this->path.'#supply-record-'.$this->draft->id)->missing('tasks.data.0.data')->missing('tasks.data.0.snapshot'));
|
||||
$before = [SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()];
|
||||
$this->get($this->path)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->has('records', 1)
|
||||
->where('focusRecordId', $this->draft->id)->where('focusChanged', false)->where('records.0.canApprove', true)->where('records.0.canSuspend', false));
|
||||
expect([SupplyRecord::query()->count(), DB::table('audit_logs')->count(), DB::table('requisitions')->count()])->toBe($before);
|
||||
$approved = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $this->draft));
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->path)->assertInertia(fn (Assert $page) => $page->where('focusChanged', true)->where('records.0.id', $approved->id)
|
||||
->where('records.0.canApprove', false)->where('records.0.canSuspend', true));
|
||||
});
|
||||
|
||||
test('supply task excludes every preparation contributor even after another person edits the current version', function () {
|
||||
supplyGrant($this->f['issuer'], ['supply_review']);
|
||||
supplyGrant($this->f['receiver'], ['supply_prepare']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$updated = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([...$this->data, 'basis' => 'Revisão sintética conservando os intervenientes.'], 'save', $this->draft));
|
||||
supplyGrant($this->f['otherReceiver'], ['supply_review']);
|
||||
foreach ([$this->f['issuer'], $this->f['receiver']] as $contributor) {
|
||||
$this->actingAs($contributor)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
expect(app(SupplyPlanning::class)->canReview($contributor, $updated))->toBeFalse();
|
||||
}
|
||||
$this->actingAs($this->f['otherReceiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)->where('tasks.data.0.id', 'supply:'.$updated->id.':review'));
|
||||
});
|
||||
|
||||
test('supply task keeps a changed source available for return but cannot silently approve it', function () {
|
||||
$this->f['stock']->update(['name' => 'Cadastro alterado depois da preparação']);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1));
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $this->draft))->assertUnprocessable();
|
||||
$returned = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'return', $this->draft));
|
||||
expect($returned->state)->toBe('returned')->and($returned->snapshot['item'])->toBe($this->draft->snapshot['item']);
|
||||
});
|
||||
|
||||
test('supply task retains older preparations across the former hundred row display limit', function () {
|
||||
$first = $this->draft;
|
||||
for ($index = 1; $index < 105; $index++) {
|
||||
app(SupplyPlanning::class)->save($this->f['issuer'], 'policy', supplyInput($this->data));
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supply:'.$first->id.':review'));
|
||||
$this->get('/requisicoes/tarefas?module=supply&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5));
|
||||
$this->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $page) => $page->has('records', 25)->where('recordPagination.total', 106));
|
||||
$this->get($this->path)->assertInertia(fn (Assert $page) => $page->has('records', 1)->where('records.0.id', $first->id)->where('records.0.canApprove', true));
|
||||
});
|
||||
|
||||
test('supply task and record focus preserve company isolation and the kind of the document', function () {
|
||||
$tenant = Tenant::query()->create(['name' => 'Outra empresa sintética']);
|
||||
$foreign = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
supplyGrant($foreign, ['supply_review']);
|
||||
$this->actingAs($foreign)->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->getJson($this->path)->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->getJson('/compras/fornecimento?view=agreements&record_id='.$this->draft->id)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('supply task does not inherit execution from maintenance and expires with the saved grant', function () {
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]);
|
||||
$this->actingAs($this->f['receiver'])->get('/requisicoes/tarefas?module=supply')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0));
|
||||
$this->get($this->path)->assertForbidden();
|
||||
expect(app(SupplyPlanning::class)->canReview($this->f['receiver'], $this->draft))->toBeFalse();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes/tarefas?module=supply')->assertForbidden();
|
||||
});
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\ReplenishmentHeartbeat;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\WarehousePilotReadiness;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
@@ -19,6 +23,15 @@ beforeEach(function () {
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->f['policy'] = app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
supplyGrant($this->f['otherReceiver'], ['admin']);
|
||||
$this->readinessOriginalStorage = app()->storagePath();
|
||||
$this->readinessStorage = $this->readinessOriginalStorage.'/framework/testing/readiness-'.Str::uuid();
|
||||
File::ensureDirectoryExists($this->readinessStorage.'/framework');
|
||||
app()->useStoragePath($this->readinessStorage);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
app()->useStoragePath($this->readinessOriginalStorage);
|
||||
File::deleteDirectory($this->readinessStorage);
|
||||
});
|
||||
|
||||
function pilotReadinessRule($test): void
|
||||
@@ -118,6 +131,31 @@ test('optional scheduler telemetry is isolated and a stale or damaged file does
|
||||
}
|
||||
});
|
||||
|
||||
test('native scheduler observation is available outside evaluation and takes precedence over helper telemetry', function () {
|
||||
$originalStorage = app()->storagePath();
|
||||
$originalEnv = app()->environment();
|
||||
$temporary = $originalStorage.'/framework/testing/native-status-'.Str::uuid();
|
||||
mkdir($temporary.'/framework', 0755, true);
|
||||
app()->useStoragePath($temporary);
|
||||
app()->instance('env', 'production');
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
try {
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 0);
|
||||
$status = app(ReplenishmentQueue::class)->status($this->f['otherReceiver']);
|
||||
expect($status['schedulerRecent'])->toBeTrue()->and($status['schedulerStatus']['result'])->toBe('passed');
|
||||
app(ReplenishmentHeartbeat::class)->record(now()->toIso8601String(), 1);
|
||||
file_put_contents($temporary.'/framework/replenishment-status.json', json_encode(['result' => 'passed', 'finished_at' => now()->toIso8601String()], JSON_THROW_ON_ERROR));
|
||||
app()->instance('env', 'payable-testing');
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus']['result'])->toBe('retry_pending');
|
||||
file_put_contents($temporary.'/framework/replenishment-command-status.json', '{broken');
|
||||
expect(app(ReplenishmentQueue::class)->status($this->f['otherReceiver'])['schedulerStatus'])->toBeNull();
|
||||
} finally {
|
||||
app()->useStoragePath($originalStorage);
|
||||
app()->instance('env', $originalEnv);
|
||||
File::deleteDirectory($temporary);
|
||||
}
|
||||
});
|
||||
|
||||
test('a recent successful scheduler does not hide durable work still awaiting recovery', function () {
|
||||
pilotReadinessRule($this);
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
@@ -143,3 +181,21 @@ test('a recent successful scheduler does not hide durable work still awaiting re
|
||||
rmdir($temporary);
|
||||
}
|
||||
});
|
||||
test('command readiness does not certify a pilot missing receipt storage or the requisition version', function (string $missing) {
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse_request_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
pilotReadinessRule($this);
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
expect(app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver'])['warehouse_scenario_ready'])->toBeTrue();
|
||||
$counts = [DB::table('requisitions')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()];
|
||||
if ($missing === 'receipts') {
|
||||
Schema::drop('requisition_commands');
|
||||
} else {
|
||||
Schema::table('requisitions', fn (Blueprint $table) => $table->dropColumn('process_version'));
|
||||
}
|
||||
$report = app(WarehousePilotReadiness::class)->inspect($this->f['otherReceiver']);
|
||||
expect($report['checks'][0]['ok'])->toBeFalse()->and($report['warehouse_scenario_ready'])->toBeFalse()->and($report['replenishment_scenario_ready'])->toBeFalse()
|
||||
->and($report['tables'][$missing === 'receipts' ? 'requisition_commands' : 'requisition_process_version'])->toBeFalse()
|
||||
->and([DB::table('requisitions')->count(), DB::table('stock_movements')->count(), DB::table('audit_logs')->count()])->toBe($counts);
|
||||
})->with(['receipts', 'version']);
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\RequisitionCatalogue;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
@@ -38,6 +39,32 @@ function taskInboxRequest($test, string $quantity = '4.000'): void
|
||||
taskInboxCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
}
|
||||
|
||||
test('task deadline filtering uses the whole authorised projection before pagination and preserves module search scope', function () {
|
||||
$this->travelTo(now()->startOfDay()->addHours(23)->addMinutes(59));
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 28; $i++) {
|
||||
$rows[] = ['id' => 'deadline:'.$i, 'module' => 'supply', 'reference' => 'PRAZO-'.$i, 'label' => 'Acompanhar origem existente', 'blocked' => false,
|
||||
'created_at' => now()->subMonth()->toIso8601String(), 'due_on' => today()->subDay()->toDateString(), 'href' => '/compras/fornecimento?view=reservations'];
|
||||
}
|
||||
foreach (['today' => today()->toDateString(), 'upcoming' => today()->addDay()->toDateString(), 'undated' => null] as $status => $date) {
|
||||
$rows[] = [...$rows[0], 'id' => 'deadline:'.$status, 'reference' => 'PRAZO-'.$status, 'due_on' => $date];
|
||||
}
|
||||
$rows[] = [...$rows[0], 'id' => 'other', 'module' => 'warehouse', 'reference' => 'OUTRO'];
|
||||
$this->mock(WorkflowTaskInbox::class)->shouldReceive('build')->andReturn($rows);
|
||||
$this->get('/requisicoes/tarefas?module=supply&due=overdue')->assertInertia(fn (Assert $p) => $p
|
||||
->where('tasks.total', 28)->has('tasks.data', 25)->where('tasks.data.0.due_status', 'overdue')
|
||||
->where('deadlineCounts.overdue', 28)->where('deadlineCounts.today', 1)->where('deadlineCounts.upcoming', 1)->where('deadlineCounts.undated', 1)
|
||||
->where('counts.warehouse', 1)->where('asOf', today()->toDateString())->where('filters.due', 'overdue')
|
||||
->where('tasks.next_page_url', fn ($url) => str_contains($url, 'due=overdue') && str_contains($url, 'module=supply')));
|
||||
$this->get('/requisicoes/tarefas?module=supply&due=overdue&page=2')->assertInertia(fn (Assert $p) => $p->has('tasks.data', 3)->where('tasks.data.0.id', 'deadline:25'));
|
||||
foreach (['today', 'upcoming', 'undated'] as $status) {
|
||||
$this->get('/requisicoes/tarefas?module=supply&due='.$status)->assertInertia(fn (Assert $p) => $p->where('tasks.total', 1)->where('tasks.data.0.due_status', $status));
|
||||
}
|
||||
$this->get('/requisicoes/tarefas?module=supply&search=PRAZO-today&due=overdue')->assertInertia(fn (Assert $p) => $p
|
||||
->where('tasks.total', 0)->where('deadlineCounts.today', 1)->where('deadlineCounts.overdue', 0));
|
||||
$this->getJson('/requisicoes/tarefas?due=invalid')->assertUnprocessable();
|
||||
});
|
||||
|
||||
function taskInboxCommand($test, User $actor, string $action, array $extra = [])
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
function foundationMfaRoutes(): void
|
||||
{
|
||||
config(['fortify.features' => [Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true])]]);
|
||||
// Native route definitions only in the disposable test application.
|
||||
require base_path('vendor/laravel/fortify/routes/routes.php');
|
||||
app('router')->getRoutes()->refreshNameLookups();
|
||||
app('router')->getRoutes()->refreshActionLookups();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
$file=realpath($argv[1]??'');$private=realpath(getcwd().'/../private');
|
||||
if(!$file||!$private||!str_starts_with($file,$private.DIRECTORY_SEPARATOR))throw new LogicException('Private QA worker input refused');
|
||||
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$p=json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);$db=Illuminate\Support\Facades\DB::connection();$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
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 LogicException('QA worker database refused');
|
||||
config(['common-payables.enabled'=>true,'common-payables.history_enabled'=>true,'common-payables.paged_read_enabled'=>true,'common-payables.source_instance'=>$p['instance']]);$u=App\Models\User::query()->findOrFail($p['actor']);auth()->setUser($u);$time=hrtime(true);
|
||||
try{$d=app(App\Services\SupplierPayables::class)->listing($u);$out=['status'=>200,'documents'=>count($d['documents']),'balances'=>array_column($d['obligations'],'balance_minor'),'time_ms'=>(hrtime(true)-$time)/1e6];}
|
||||
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$out=['status'=>$e->getStatusCode()];}
|
||||
file_put_contents($file.'.result',json_encode($out,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||
Reference in New Issue
Block a user