Files
compras/tests/Feature/SupplyCoordinationTest.php
2026-11-03 11:51:44 +02:00

514 lines
43 KiB
PHP

<?php
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';
beforeEach(function () {
$this->f = receiptFixture('100.000');
supplyGrant($this->f['issuer'], ['procurement', 'supply_delivery', 'supply_technical_prepare']);
supplyGrant($this->f['receiver'], ['warehouse', 'supply_reserve', 'supply_technical_review', 'supply_read']);
$this->actingAs($this->f['receiver']);
});
function reserveData(array $f, string $reference = 'NEC-1', int $quantity = 30000): array
{
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,
'expected_on' => today()->addWeek()->toDateString(), 'confirmed_on' => today()->toDateString(), 'evidence' => 'Confirmação fictícia do fornecedor'];
}
function coordinationIssue($test, string $quantity): object
{
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'SAIDA-'.Str::random(8), 'idempotency_key' => (string) Str::uuid(),
'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));
$this->postJson('/compras/fornecimento/reservation', $input)->assertRedirect();
$this->postJson('/compras/fornecimento/reservation', $input)->assertRedirect();
$reserve = SupplyRecord::query()->sole();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'])->toHaveCount(1)->and($events['reservations'][0]['quantity'])->toBe(30000);
$issue = coordinationIssue($this, '20.000');
$apply = supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 20000], 'fulfil', $reserve);
$this->postJson('/compras/fornecimento/reservation', $apply)->assertRedirect();
$this->postJson('/compras/fornecimento/reservation', $apply)->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'][0]['quantity'])->toBe(10000)->and($events['consumed_today'])->toBe(0)->and($events['fulfilled_needs'][0]['quantity'])->toBe(20000)
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(80000);
$second = supplyInput(reserveData($this->f, 'NEC-2', 10000));
$this->postJson('/compras/fornecimento/reservation', $second)->assertRedirect();
$other = app(SupplyPlanning::class)->latest('reservation')->latest('id')->first();
$this->postJson('/compras/fornecimento/reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 1], 'fulfil', $other))->assertUnprocessable();
});
test('future confirmations subtract actual received quantity and cancellation never erases stock', function () {
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertRedirect();
$confirmation = SupplyRecord::query()->sole();
$this->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertUnprocessable();
$receipt = receiptPayload($this->f, '20.000');
unset($receipt['items'][0]['stock_item_id']);
$this->actingAs($this->f['receiver'])->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', $receipt)->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['receipts'])->toHaveCount(1)->and($events['receipts'][0]['quantity'])->toBe(80000);
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput([], 'cancel', $confirmation))->assertRedirect();
$this->actingAs($this->f['receiver']);
expect(app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString())['receipts'])->toBe([])
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(20000);
});
test('late confirmations produce warnings instead of masking earlier shortages', function () {
$data = [...deliveryData($this->f), 'expected_on' => today()->subDay()->toDateString()];
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/delivery', supplyInput($data))->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['receipts'])->toBe([])->and($events['warnings'])->toHaveCount(1);
});
test('technical equivalence is directional independently reviewed and invalidated by source change', function () {
$substitute = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'SUB-1', 'name' => 'Substituto do ensaio', 'unit' => 'cx']);
$data = ['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'substitute_id' => $substitute->id, 'application' => 'Aplicação do ensaio', 'compatibility' => 'equivalent',
'evidence' => 'Especificações e resultados do ensaio técnico.', 'factor_numerator' => 1, 'factor_denominator' => 1, 'valid_until' => today()->addYear()->toDateString()];
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/alternative', supplyInput($data))->assertRedirect();
$draft = SupplyRecord::query()->sole();
$this->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertForbidden();
supplyGrant($this->f['issuer'], ['supply_technical_review']);
$this->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertUnprocessable();
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/alternative', supplyInput([], 'approve', $draft))->assertRedirect();
$approved = app(SupplyPlanning::class)->latest('alternative')->sole();
expect(app(SupplyCoordination::class)->eligibleAlternative($approved))->toBeTrue()
->and(app(SupplyPlanning::class)->latest('alternative')->where('stock_item_id', $substitute->id)->exists())->toBeFalse();
$substitute->update(['name' => 'Nova especificação de cadastro']);
expect(app(SupplyCoordination::class)->eligibleAlternative($approved))->toBeFalse()->and($approved->snapshot['substitute']['name'])->toBe('Substituto do ensaio');
});
test('known reservations survive unknown forecast and same day actual consumption is not deducted twice', function () {
$p = [...supplyPolicy($this->f), 'daily_milli' => null, 'horizon_days' => 3];
$result = app(ReplenishmentProjection::class)->calculate($p, 100000, today()->toDateString(), [['date' => today()->toDateString(), 'quantity' => 30000, 'additional' => false]]);
expect($result['available'])->toBeTrue()->and($result['demand_complete'])->toBeFalse()->and($result['series'][0]['balance_milli'])->toBe(70000);
$result = app(ReplenishmentProjection::class)->calculate([...$p, 'daily_milli' => 10000], 70000, today()->toDateString(), [], [], 30000);
expect($result['series'][0]['balance_milli'])->toBe(70000)->and($result['series'][1]['balance_milli'])->toBe(60000);
});
test('an early issue satisfies its future reservation without consuming its forecast a second time', function () {
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f, '100.000'))->assertRedirect();
$data = [...reserveData($this->f), 'needed_on' => today()->addDay()->toDateString()];
$this->postJson('/compras/fornecimento/reservation', supplyInput($data))->assertRedirect();
$reservation = SupplyRecord::query()->sole();
$issue = coordinationIssue($this, '30.000');
$this->postJson('/compras/fornecimento/reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 30000], 'fulfil', $reservation))->assertRedirect();
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
expect($events['reservations'])->toBe([])->and($events['fulfilled_needs'][0]['date'])->toBe(today()->addDay()->toDateString());
$projection = app(ReplenishmentProjection::class)->calculate([...supplyPolicy($this->f), 'horizon_days' => 3], 70000, today()->toDateString(), $events['reservations'], $events['receipts'], $events['consumed_today'], $events['fulfilled_needs']);
expect($projection['series'][0]['balance_milli'])->toBe(60000)->and($projection['series'][1]['balance_milli'])->toBe(60000)->and($projection['series'][2]['balance_milli'])->toBe(50000);
});
test('future combined deliveries expose excess even when the proposed first receipt respects the maximum', function () {
$p = [...supplyPolicy($this->f), 'daily_milli' => 0, 'minimum_milli' => 0, 'pack_milli' => 1000, 'horizon_days' => 3];
$start = today()->toDateString();
$projection = app(ReplenishmentProjection::class)->calculate($p, 100000, $start, [['date' => $start, 'quantity' => 80000]], [['date' => today()->addDay()->toDateString(), 'quantity' => 100000, 'confirmed' => true]]);
expect($projection['excess_milli'])->toBe(0)->and($projection['future_excess_milli'])->toBe(80000)->and($projection['requires_exception'])->toBeTrue();
});
test('coordination permission never grants unrelated workflow commands or crosses companies', function () {
$this->postJson('/compras/fornecimento/delivery', supplyInput(deliveryData($this->f)))->assertForbidden();
$foreign = receiptFixture();
$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);
});