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]; } 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.']); } 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(); });