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); });