commit13
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryCounts;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
|
||||
throw new LogicException('Worker apenas em base PostgreSQL descartável.');
|
||||
}
|
||||
require_once dirname(__DIR__).'/Support/ReceiptFixture.php';
|
||||
require_once dirname(__DIR__).'/Support/SupplyFixture.php';
|
||||
if (($argv[1] ?? '') === 'prepare_reversal') {
|
||||
$f = receiptFixture('100.000');
|
||||
supplyGrant($f['receiver'], ['warehouse', 'inventory_reversal_prepare']);
|
||||
supplyGrant($f['issuer'], ['inventory_reversal_review']);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
$issue = app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'issue', 'reference' => 'ISSUE', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $f['warehouse']->id,
|
||||
'stock_item_id' => $f['stock']->id, 'responsible_id' => $f['receiver']->id, 'quantity' => '3.000', 'reason' => 'Saída original para disputa de estorno.']);
|
||||
$records = [];
|
||||
foreach ([1, 2] as $number) {
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$draft = app(InventoryReversals::class)->save($f['receiver'], supplyInput(['reference' => 'REV-'.$number, 'source_kind' => 'operation', 'source_operation_id' => $issue->id]));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$approved = app(InventoryReversals::class)->save($f['issuer'], supplyInput([], 'approve', $draft));
|
||||
$records[] = ['record_key' => $approved->record_key, 'version' => $approved->version];
|
||||
}
|
||||
echo json_encode(['user' => $f['receiver']->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id, 'records' => $records], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
if (($argv[1] ?? '') === 'prepare_return') {
|
||||
$f = receiptFixture('100.000');
|
||||
supplyGrant($f['receiver'], ['warehouse', 'inventory_disposition_prepare']);
|
||||
supplyGrant($f['issuer'], ['inventory_disposition_review']);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$receipt = app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
$records = [];
|
||||
foreach ([1, 2] as $number) {
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$draft = app(InventoryDispositions::class)->save($f['receiver'], supplyInput(['kind' => 'supplier_return', 'reference' => 'RET-'.$number,
|
||||
'stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'receipt_item_id' => $receipt->items->first()->id, 'quantity' => '7.000']));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$approved = app(InventoryDispositions::class)->save($f['issuer'], supplyInput([], 'approve', $draft));
|
||||
$records[] = ['record_key' => $approved->record_key, 'version' => $approved->version];
|
||||
}
|
||||
echo json_encode(['user' => $f['receiver']->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id, 'records' => $records], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
if (in_array($argv[1] ?? '', ['prepare_lots', 'prepare_count', 'prepare_policy'], true)) {
|
||||
$f = receiptFixture('100.000');
|
||||
supplyGrant($f['issuer'], ['inventory_lot_prepare', 'inventory_adjust_review']);
|
||||
supplyGrant($f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release']);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($f['issuer'], supplyInput(['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'RACE', 'expires_on' => today()->addDays(10)->toDateString()]]], 'prepare'));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(InventoryLots::class)->save($f['receiver'], supplyInput([], 'activate', $draft));
|
||||
$lot = app(InventoryLots::class)->context($f['stock']->id, $f['warehouse']->id)['lots'][0];
|
||||
$metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
|
||||
$release = app(InventoryLots::class)->save($f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $f['warehouse']->id, 'state_version' => 0], 'release', $metadata));
|
||||
$destination = Warehouse::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino da corrida']);
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$destinationDraft = app(InventoryLots::class)->save($f['issuer'], supplyInput(['stock_item_id' => $f['stock']->id, 'warehouse_id' => $destination->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(InventoryLots::class)->save($f['receiver'], supplyInput([], 'activate', $destinationDraft));
|
||||
$count = null;
|
||||
$policy = null;
|
||||
if ($argv[1] === 'prepare_policy') {
|
||||
$active = app(InventoryLots::class)->setup($f['stock']->id, $f['warehouse']->id);
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$policy = app(InventoryLots::class)->save($f['issuer'], supplyInput([...$active->data, 'minimum_remaining_days' => 2, 'lots' => []], 'prepare', $active));
|
||||
}
|
||||
if ($argv[1] === 'prepare_count') {
|
||||
$count = app(StockWorkflow::class)->record($f['receiver'], ['kind' => 'count', 'reference' => 'CNT-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $f['warehouse']->id, 'stock_item_id' => $f['stock']->id, 'responsible_id' => $f['receiver']->id, 'quantity' => '9.000', 'reason' => 'Contagem para disputa de ajuste físico.',
|
||||
'ledger_version' => app(StockWorkflow::class)->balance($f['stock']->id, $f['warehouse']->id)['version'],
|
||||
'lot_version' => app(InventoryCounts::class)->source($f['stock']->id, $f['warehouse']->id)['hash'], 'lot_counts' => [['lot_id' => $lot['id'], 'quantity' => '9.000']]]);
|
||||
}
|
||||
echo json_encode(['user' => $f['receiver']->id, 'preparer' => $f['issuer']->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id,
|
||||
'policy_record_key' => $policy?->record_key, 'policy_version' => $policy?->version,
|
||||
'lot_id' => $lot['id'], 'reviewer' => $f['issuer']->id, 'count_id' => $count?->id, 'destination' => $destination->id, 'record_key' => $metadata->record_key, 'version' => $metadata->version, 'state_version' => $release->version], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
if (($argv[1] ?? '') === 'prepare_suggestion') {
|
||||
require_once dirname(__DIR__).'/Support/SuggestionFixture.php';
|
||||
$f = suggestionFixture();
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$draft = app(SupplyPlanning::class)->save($f['issuer'], 'suggestion', supplyInput(['policy_key' => $f['policy']->record_key]));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$approved = app(SupplyPlanning::class)->save($f['receiver'], 'suggestion', supplyInput([], 'approve', $draft));
|
||||
echo json_encode(['user' => $f['issuer']->id, 'tenant' => $f['tenant']->id, 'record_key' => $approved->record_key, 'version' => $approved->version, 'cost_center_id' => $f['r']->cost_center_id], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
if (($argv[1] ?? '') === 'prepare') {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['receiver'], ['supply_reserve']);
|
||||
echo json_encode(['user' => $f['receiver']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id, 'tenant' => $f['tenant']->id], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
$input = json_decode($argv[2], true, 16, JSON_THROW_ON_ERROR);
|
||||
$actor = User::query()->findOrFail(in_array($argv[1] ?? '', ['hold_lot_adjust', 'lot_adjust'], true) ? $input['reviewer'] : (($argv[1] ?? '') === 'hold_lot_identify' ? $input['preparer'] : $input['user']));
|
||||
auth()->guard()->setUser($actor);
|
||||
if (in_array($argv[1] ?? '', ['hold', 'hold_suggestion', 'hold_lot_issue', 'hold_lot_identify', 'hold_lot_transfer', 'hold_lot_adjust', 'hold_disposition', 'hold_stock_reversal'], true)) {
|
||||
$held = false;
|
||||
DB::listen(function (QueryExecuted $event) use (&$held) {
|
||||
if (! $held && str_contains($event->sql, '"tenants"') && str_contains($event->sql, 'for update')) {
|
||||
$held = true;
|
||||
echo "locked\n";
|
||||
fflush(STDOUT);
|
||||
usleep(2000000);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (in_array($argv[1] ?? '', ['hold_stock_reversal', 'stock_reversal'], true)) {
|
||||
$source = $input['records'][$input['selected']];
|
||||
$record = app(InventoryReversals::class)->save($actor, ['operation_key' => $input['operation_key'], ...$source, 'action' => 'execute',
|
||||
'reason' => 'Estornos concorrentes ligados à mesma origem.', 'data' => ['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo da correcção física no ensaio de concorrência.']]);
|
||||
} elseif (($argv[1] ?? '') === 'lot_activate') {
|
||||
$record = app(InventoryLots::class)->save($actor, ['operation_key' => $input['operation_key'], 'record_key' => $input['policy_record_key'], 'version' => $input['policy_version'],
|
||||
'action' => 'activate', 'reason' => 'Conferência de impacto concorrente com saída real.', 'data' => []]);
|
||||
} elseif (in_array($argv[1] ?? '', ['hold_disposition', 'disposition'], true)) {
|
||||
$source = $input['records'][$input['selected']];
|
||||
$record = app(InventoryDispositions::class)->save($actor, ['operation_key' => $input['operation_key'], ...$source, 'action' => 'execute',
|
||||
'reason' => 'Execuções concorrentes de devoluções à mesma origem.', 'data' => ['event_on' => today()->toDateString(), 'evidence' => 'Guia de devolução assinada no ensaio de concorrência.']]);
|
||||
} elseif (in_array($argv[1] ?? '', ['hold_lot_adjust', 'lot_adjust'], true)) {
|
||||
$record = app(StockWorkflow::class)->record($actor, ['kind' => 'adjustment', 'reference' => 'ADJ-'.Str::uuid(), 'idempotency_key' => $input['operation_key'],
|
||||
'responsible_id' => $actor->id, 'warehouse_id' => $input['warehouse'], 'stock_item_id' => $input['stock'], 'quantity' => '0.000', 'count_id' => $input['count_id'], 'reason' => 'Duas conferências concorrentes da mesma contagem.']);
|
||||
} elseif (in_array($argv[1] ?? '', ['hold_lot_issue', 'lot_issue', 'hold_lot_transfer', 'lot_transfer'], true)) {
|
||||
$transfer = in_array($argv[1], ['hold_lot_transfer', 'lot_transfer'], true);
|
||||
$record = app(StockWorkflow::class)->record($actor, ['kind' => $transfer ? 'transfer' : 'issue', ...($transfer ? ['destination_warehouse_id' => $input['destination']] : []), 'reference' => 'RACE-'.Str::uuid(), 'idempotency_key' => $input['operation_key'],
|
||||
'responsible_id' => $actor->id, 'warehouse_id' => $input['warehouse'], 'stock_item_id' => $input['stock'], 'quantity' => '7.000', 'reason' => 'Saídas concorrentes do mesmo lote libertado.']);
|
||||
} elseif (in_array($argv[1] ?? '', ['hold_lot_identify', 'lot_release'], true)) {
|
||||
$record = app(InventoryLots::class)->save($actor, ['operation_key' => $input['operation_key'], 'record_key' => $input['record_key'], 'version' => $input['version'],
|
||||
'action' => $argv[1] === 'hold_lot_identify' ? 'identify' : 'release', 'reason' => 'Conferência concorrente de identificação e libertação.',
|
||||
'data' => $argv[1] === 'hold_lot_identify' ? ['code' => 'CORRECTED', 'expires_on' => today()->addDays(5)->toDateString()] : ['lot_id' => $input['lot_id'], 'warehouse_id' => $input['warehouse'], 'state_version' => $input['state_version']]]);
|
||||
} elseif (in_array($argv[1] ?? '', ['hold_suggestion', 'convert_suggestion'], true)) {
|
||||
$record = app(SupplyPlanning::class)->save($actor, 'suggestion', ['operation_key' => $input['operation_key'], 'record_key' => $input['record_key'], 'version' => $input['version'], 'action' => 'convert', 'reason' => 'Prova de conversão concorrente da sugestão.', 'data' => ['cost_center_id' => $input['cost_center_id']]]);
|
||||
} else {
|
||||
$record = app(SupplyPlanning::class)->save($actor, 'reservation', ['operation_key' => $input['operation_key'], 'version' => 0, 'action' => 'save', 'reason' => 'Prova de duas reservas concorrentes.',
|
||||
'data' => ['stock_item_id' => $input['stock'], 'warehouse_id' => $input['warehouse'], 'quantity_milli' => 1000, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => $input['reference']]]);
|
||||
}
|
||||
echo json_encode(['result' => 'saved', 'id' => $record->id], JSON_THROW_ON_ERROR);
|
||||
} catch (ValidationException $e) {
|
||||
echo json_encode(['result' => 'rejected', 'fields' => array_keys($e->errors())], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! app()->environment('testing') || DB::getDriverName() !== 'pgsql' || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName())) {
|
||||
throw new LogicException('Apenas base descartável.');
|
||||
}
|
||||
require_once dirname(__DIR__).'/Support/ReceiptFixture.php';
|
||||
require_once dirname(__DIR__).'/Support/SupplyFixture.php';
|
||||
if (($argv[1] ?? '') === 'prepare') {
|
||||
$f = receiptFixture('100.000');
|
||||
$reviewer = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['issuer']->department_id]);
|
||||
supplyGrant($f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($reviewer, ['warehouse_request_review']);
|
||||
supplyGrant($f['receiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$r = app(WarehouseRequests::class)->draft($f['issuer'], ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $f['warehouse']->id, 'cost_center_id' => $f['r']->cost_center_id,
|
||||
'destination' => 'Oficina interna', 'reason' => 'Material para execução do ensaio.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $f['stock']->id, 'quantity' => '4.000', 'use' => 'internal']]]);
|
||||
app(WarehouseFulfilment::class)->command($f['issuer'], $r->id, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'submit', 'reason' => 'Submissão da necessidade de ensaio.']);
|
||||
auth()->guard()->setUser($reviewer);
|
||||
app(WarehouseFulfilment::class)->command($reviewer, $r->id, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Autorização independente de ensaio.']);
|
||||
echo json_encode(['user' => $f['receiver']->id, 'request' => $r->id, 'tenant' => $f['tenant']->id, 'stock' => $f['stock']->id, 'warehouse' => $f['warehouse']->id,
|
||||
'input' => ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega física efectivamente confirmada.', 'lines' => [['index' => 0, 'quantity' => '4.000']]]], JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
$data = json_decode($argv[2], true, 32, JSON_THROW_ON_ERROR);
|
||||
$actor = User::query()->withoutGlobalScopes()->findOrFail($data['user']);
|
||||
auth()->guard()->setUser($actor);
|
||||
if (($argv[1] ?? '') === 'hold') {
|
||||
$held = false;
|
||||
DB::listen(function (QueryExecuted $query) use (&$held) {
|
||||
if (! $held && str_contains($query->sql, '"tenants"') && str_contains(strtolower($query->sql), 'for update')) {
|
||||
$held = true;
|
||||
echo "locked\n";
|
||||
flush();
|
||||
usleep(2000000);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
$event = app(WarehouseFulfilment::class)->command($actor, $data['request'], $data['input']);
|
||||
echo json_encode(['result' => 'saved', 'event' => $event->id], JSON_THROW_ON_ERROR);
|
||||
} catch (ValidationException $error) {
|
||||
echo json_encode(['result' => 'rejected'],JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f=suggestionFixture();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->input=['operation_key'=>(string)Str::uuid(),'version'=>0,'action'=>'save','reason'=>'Parametrização autorizada para o ensaio isolado.',
|
||||
'data'=>['policy_key'=>$this->f['policy']->record_key,'requester_id'=>$this->f['issuer']->id,'cost_center_id'=>$this->f['r']->cost_center_id,'unit_price'=>null,'tax_rate'=>null,'needed_days'=>5,'valid_until'=>today()->addMonth()->toDateString()]];
|
||||
});
|
||||
|
||||
function activateAutomaticRule($test) {
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$rule=app(AutomaticPurchases::class)->save($test->f['issuer'],$test->input);
|
||||
$test->actingAs($test->f['receiver']);
|
||||
return app(AutomaticPurchases::class)->save($test->f['receiver'],['operation_key'=>(string)Str::uuid(),'record_key'=>$rule->record_key,'version'=>$rule->version,'action'=>'approve','reason'=>'Activação independente da parametrização verificada.','data'=>[]]);
|
||||
}
|
||||
|
||||
function automaticIssue($test,string $quantity) {
|
||||
$test->actingAs($test->f['receiver']);
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'],['kind'=>'issue','reference'=>'TEST-'.Str::uuid(),'idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$test->f['issuer']->id,'warehouse_id'=>$test->f['warehouse']->id,'stock_item_id'=>$test->f['stock']->id,'quantity'=>$quantity,'destination'=>'Manutenção interna','reason'=>'Consumo físico confirmado no ensaio isolado.']);
|
||||
}
|
||||
|
||||
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals',function(){
|
||||
$rule=activateAutomaticRule($this);
|
||||
expect($rule->data['unit_price'])->toBe('10.00');
|
||||
$count=Requisition::query()->count();
|
||||
automaticIssue($this,'100.000');
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
|
||||
$need=app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
$r=Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
|
||||
expect(Requisition::query()->count())->toBe($count+1)->and($r->status)->toBe('draft')->and($r->requester_id)->toBe($this->f['issuer']->id)->and($r->cost_center_id)->toBe($this->f['r']->cost_center_id)
|
||||
->and($r->items()->sole()->quantity)->toBe('84.000')->and($r->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0)
|
||||
->and(DB::table('audit_logs')->where('action','requisition.automatically_created')->value('user_id'))->toBe($this->f['receiver']->id);
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
|
||||
expect(Requisition::query()->count())->toBe($count+1)->and(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('covered');
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(RequisitionWorkflow::class)->submit($this->f['issuer'],$r);
|
||||
expect($r->fresh()->status)->toBe('pending')->and($r->steps()->first()->role)->toBe('department_head');
|
||||
});
|
||||
|
||||
test('missing configuration records a pending need and never invents a requester or budget',function(){
|
||||
$before=Requisition::query()->count();
|
||||
automaticIssue($this,'120.000');
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
|
||||
$need=app(SupplyPlanning::class)->latest('auto_need')->sole();
|
||||
expect($need->state)->toBe('pending')->and($need->data['purchases'])->toBe([])->and($need->data['message'])->toContain('Parametrize')->and(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules',function(){
|
||||
supplyGrant($this->f['issuer'],['supply_review']);
|
||||
$rule=app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input);
|
||||
expect(app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input)->id)->toBe($rule->id);
|
||||
$decision=['operation_key'=>(string)Str::uuid(),'record_key'=>$rule->record_key,'version'=>1,'action'=>'approve','reason'=>'Tentativa de activar a própria regra.','data'=>[]];
|
||||
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['issuer'],$decision))->toThrow(ValidationException::class);
|
||||
$this->input['data']['needed_days']=9;
|
||||
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$decision['version']=0;
|
||||
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['receiver'],$decision))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('expired or changed sources keep the need pending without preventing the physical operation',function(){
|
||||
activateAutomaticRule($this);
|
||||
$before=Requisition::query()->count();
|
||||
$this->travel(32)->days();
|
||||
automaticIssue($this,'120.000');
|
||||
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
|
||||
expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('pending')->and(Requisition::query()->count())->toBe($before)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(0);
|
||||
});
|
||||
|
||||
test('configuration UI separates preparation review and read and does not generate purchases on GET',function(){
|
||||
$before=Requisition::query()->count();
|
||||
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn(Assert $p)=>$p->component('stock/replenishment')->where('canPrepare',true)->where('canReview',false)->has('requesters',1));
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn(Assert $p)=>$p->where('canPrepare',false)->where('canReview',true)->has('requesters',0));
|
||||
expect(Requisition::query()->count())->toBe($before);
|
||||
});
|
||||
@@ -309,9 +309,18 @@ test('triage reuses the submitted profile after the deadline without rewriting t
|
||||
$this->get('/pessoas')->assertInertia(fn (Assert $p) => $p->where('workdesk.tasks.0.action', 'triage'));
|
||||
$body = electronicTriagePayload($this);
|
||||
$url = '/pessoas/'.$this->process->id.'/recebidas/'.$submission->id.'/triagem';
|
||||
$this->get('/pessoas/relatorios?view=applications')->assertInertia(fn (Assert $p) => $p
|
||||
->where('applicationMetrics.'.$this->process->id.'.portal_received', 1)
|
||||
->where('applicationMetrics.'.$this->process->id.'.portal_pending', 1)
|
||||
->where('applicationMetrics.'.$this->process->id.'.registered', 0));
|
||||
$this->postJson($url, $body)->assertRedirect();
|
||||
$this->postJson($url, $body)->assertRedirect();
|
||||
$application = DB::table('people_applications')->sole();
|
||||
$this->get('/pessoas/relatorios?view=applications')->assertInertia(fn (Assert $p) => $p
|
||||
->where('applicationMetrics.'.$this->process->id.'.registered', 1)
|
||||
->where('applicationMetrics.'.$this->process->id.'.portal_received', 1)
|
||||
->where('applicationMetrics.'.$this->process->id.'.portal_accepted', 1)
|
||||
->where('applicationMetrics.'.$this->process->id.'.portal_pending', 0));
|
||||
expect($application->name)->toBe('Pessoa fictícia')->and($application->reference)->toBe($submission->reference)->and($application->created_by)->toBe($this->admin->id)
|
||||
->and(DB::table('candidate_submissions')->sole()->snapshot)->toBe($snapshot)->and(DB::table('candidate_triage')->count())->toBe(1);
|
||||
$this->postJson('/pessoas/'.$this->process->id.'/close', $close)->assertRedirect();
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\WorkflowNotices;
|
||||
use App\Services\WorkflowTasks;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actor = $this->f['issuer'];
|
||||
$this->f['tenant']->update(['first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'rules_provisional' => false]);
|
||||
$this->head = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
Budget::query()->create(['tenant_id' => $this->actor->tenant_id, 'cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 1000000, 'includes_tax' => true, 'reason' => 'Dotação sintética aprovada', 'created_by' => $this->actor->id, 'approved_by' => $this->head->id]);
|
||||
$this->data = ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => now()->addWeek()->toDateString(), 'justification' => 'Necessidade operacional sintética.',
|
||||
'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00']]];
|
||||
$this->actingAs($this->actor);
|
||||
});
|
||||
|
||||
test('autonomous adapter executes shared lifecycle with one request one reserve and independent versioned approval', function () {
|
||||
$count = Requisition::query()->count();
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
expect(Requisition::query()->count())->toBe($count + 1);
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$submit = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
|
||||
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->where('kind', 'reserve')->count())->toBe(1)
|
||||
->and($r->fresh()->process_version)->toBe(2)->and($r->steps()->count())->toBe(1);
|
||||
$decision = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => 'approved'];
|
||||
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertForbidden();
|
||||
$this->actingAs($this->head)->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
|
||||
expect($r->fresh()->status)->toBe('approved')->and($r->fresh()->process_version)->toBe(3)
|
||||
->and(DB::table('requisition_commands')->where('tenant_id', $this->actor->tenant_id)->count())->toBe(3);
|
||||
$this->get('/requisicoes/'.$r->id)->assertOk();
|
||||
});
|
||||
|
||||
test('source adapter refuses changed replay stale version revoked role and another tenant', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes', [...$this->data, 'justification' => 'Outro conteúdo para a mesma operação.'])->assertConflict();
|
||||
$this->putJson('/requisicoes/'.$r->id, [...$this->data, 'operation_key' => (string) Str::uuid(), 'process_version' => 99])->assertConflict();
|
||||
$this->actor->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$this->actingAs($this->actor)->postJson('/requisicoes', $this->data)->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['issuer'])->get('/requisicoes/'.$r->id)->assertNotFound();
|
||||
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('failure after reserve rolls back source steps history and command receipt and exact retry recovers', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$command = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
|
||||
$this->partialMock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->andReturnUsing(function (...$args) {
|
||||
if ($args[1] === 'requisition.submitted') {
|
||||
throw new RuntimeException('Injected audit failure');
|
||||
}(new RequisitionWorkflow)->audit(...$args);
|
||||
});
|
||||
});
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->postJson('/requisicoes/'.$r->id.'/submeter', $command))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0)->and($r->steps()->count())->toBe(0)->and($r->fresh()->process_version)->toBe(1);
|
||||
$this->app->forgetInstance(RequisitionWorkflow::class);
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', $command)->assertRedirect();
|
||||
expect(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(1)->and($r->fresh()->status)->toBe('pending');
|
||||
});
|
||||
|
||||
test('source additive migration preserves existing data and recovers transactional schema failure without code rollback', function () {
|
||||
$migration = require base_path('database/migrations/2026_11_01_000046_add_requisition_command_versions.php');
|
||||
$migration->down();
|
||||
$baseline = DB::table('requisitions')->where('id', $this->f['r']->id)->first();
|
||||
$fail = true;
|
||||
DB::listen(function ($q) use (&$fail) {
|
||||
if ($fail && str_contains($q->sql, 'create table "requisition_commands"')) {
|
||||
$fail = false;
|
||||
throw new RuntimeException('Injected source schema failure');
|
||||
}
|
||||
});
|
||||
expect(fn () => DB::transaction(fn () => $migration->up()))->toThrow(RuntimeException::class);
|
||||
expect(Schema::hasColumn('requisitions', 'process_version'))->toBeFalse()
|
||||
->and((array) DB::table('requisitions')->where('id', $this->f['r']->id)->first())->toBe((array) $baseline);
|
||||
DB::transaction(fn () => $migration->up());
|
||||
$after = (array) DB::table('requisitions')->where('id', $this->f['r']->id)->first();
|
||||
unset($after['process_version']);
|
||||
expect($after)->toBe((array) $baseline)->and(DB::table('requisition_commands')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('approval routing connects the existing stage to eligible people without changing decisions or granting powers', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$admin = User::factory()->create(['tenant_id' => $r->tenant_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$wrongDepartment = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => null, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$foreign = receiptFixture();
|
||||
$before = $r->fresh()->toArray();
|
||||
$audits = DB::table('audit_logs')->count();
|
||||
$this->actingAs($this->actor)->get('/requisicoes/'.$r->id)->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('approvalRouting.status', 'available')->where('approvalRouting.task_key', 'requisition.approve.department_head')
|
||||
->has('approvalRouting.people', 1)->where('approvalRouting.people.0.id', $this->head->id)
|
||||
->missing('approvalRouting.people.0.email'));
|
||||
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->count())->toBe($audits);
|
||||
$this->head->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$this->get('/requisicoes/'.$r->id)->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('approvalRouting.status', 'unavailable')->has('approvalRouting.people', 0));
|
||||
$this->actingAs($foreign['issuer'])->get('/requisicoes/'.$r->id)->assertNotFound();
|
||||
});
|
||||
|
||||
test('profile task catalogue covers the existing permissions without creating a second authority source', function () {
|
||||
$catalogue = app(WorkflowTasks::class)->catalogue();
|
||||
expect(array_keys($catalogue))->toBe(array_keys(OrganizationAccess::PERMISSIONS))
|
||||
->and($catalogue['department_head'][0]['key'])->toBe('requisition.approve.department_head')
|
||||
->and($catalogue['procurement'][0]['key'])->toBe('requisition.approve.procurement')
|
||||
->and($catalogue['finance'][0]['key'])->toBe('requisition.approve.finance');
|
||||
$this->actor->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$this->actingAs($this->actor)->get('/administracao/parametrizacao')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('workflowTasks.department_head.0.key', 'requisition.approve.department_head'));
|
||||
});
|
||||
|
||||
test('internal notices are created once with submission and advance only after a valid decision', function () {
|
||||
$this->f['tenant']->update(['first_limit_minor' => 1, 'second_limit_minor' => 2]);
|
||||
$buyer = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$submit = ['operation_key' => (string) Str::uuid(), 'process_version' => 1];
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', $submit)->assertRedirect();
|
||||
expect(DB::table('workflow_notices')->count())->toBe(1);
|
||||
$notice = DB::table('workflow_notices')->sole();
|
||||
expect($notice->recipient_id)->toBe($this->head->id);
|
||||
$this->postJson('/requisicoes/avisos/'.$notice->id.'/ler')->assertNotFound();
|
||||
$this->actingAs($this->head)->get('/requisicoes/avisos')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p->where('unread', 1)->where('notices.total', 1));
|
||||
$before = $r->fresh()->toArray();
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
|
||||
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('audit_logs')->where('action', 'workflow.notice_read')->count())->toBe(1);
|
||||
$decision = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => 'approved'];
|
||||
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/decidir', $decision)->assertRedirect();
|
||||
expect(DB::table('workflow_notices')->count())->toBe(2);
|
||||
$this->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
|
||||
$this->actingAs($buyer)->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('unread', 1)->where('notices.total', 1));
|
||||
$buyer->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$this->actingAs($buyer)->get('/requisicoes/avisos')->assertForbidden();
|
||||
expect(app(WorkflowNotices::class)->visible($buyer))->toBe([]);
|
||||
});
|
||||
|
||||
test('failure to persist a notice rolls back the submission reservation audit and command receipt together', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$audits = DB::table('audit_logs')->count();
|
||||
$this->mock(WorkflowNotices::class)->shouldReceive('capture')->once()->andThrow(new RuntimeException('Injected notice persistence failure'));
|
||||
expect(fn () => app(RequisitionWorkflow::class)->submit($this->actor, $r, (string) Str::uuid(), 1))->toThrow(RuntimeException::class);
|
||||
expect($r->fresh()->status)->toBe('draft')->and($r->steps()->count())->toBe(0)
|
||||
->and(DB::table('workflow_notices')->count())->toBe(0)->and(DB::table('budget_entries')->where('requisition_id', $r->id)->count())->toBe(0)
|
||||
->and(DB::table('audit_logs')->count())->toBe($audits);
|
||||
});
|
||||
|
||||
test('requester receives one final outcome notice with the original decision and current state', function (string $decision) {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$payload = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'decision' => $decision, 'comment' => 'Fundamento da decisão final de ensaio.'];
|
||||
$this->actingAs($this->head)->postJson('/requisicoes/'.$r->id.'/decidir', $payload)->assertRedirect();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/decidir', $payload)->assertRedirect();
|
||||
expect(DB::table('workflow_notices')->where('requisition_id', $r->id)->count())->toBe(2);
|
||||
$this->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
|
||||
$this->actingAs($this->actor)->get('/requisicoes/avisos?view=outcomes')->assertOk()->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('notices.total', 1)->where('notices.data.0.kind', 'outcome')->where('notices.data.0.outcome', $decision)
|
||||
->where('notices.data.0.current_status', $decision)->missing('notices.data.0.comment'));
|
||||
$this->get('/requisicoes/avisos?view=tasks')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
|
||||
$notice = DB::table('workflow_notices')->where('recipient_id', $this->actor->id)->sole();
|
||||
$before = $r->fresh()->toArray();
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
|
||||
$this->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertRedirect();
|
||||
expect($r->fresh()->toArray())->toBe($before);
|
||||
$this->get('/requisicoes/avisos?view=unread')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 0));
|
||||
$this->actingAs($this->head)->post('/requisicoes/avisos/'.$notice->id.'/ler')->assertNotFound();
|
||||
if ($decision === 'approved') {
|
||||
// Simulate a later authoritative state; the test does not implement cancellation.
|
||||
$r->update(['status' => 'cancelled']);
|
||||
$this->actingAs($this->actor)->get('/requisicoes/avisos?view=outcomes')->assertInertia(fn (AssertableInertia $p) => $p
|
||||
->where('notices.data.0.outcome', 'approved')->where('notices.data.0.current_status', 'cancelled'));
|
||||
}
|
||||
})->with(['approved', 'rejected']);
|
||||
|
||||
test('failure to persist requester outcome rolls back the final decision and preserves its pending task', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$audits = DB::table('audit_logs')->count();
|
||||
$this->mock(WorkflowNotices::class)->shouldReceive('capture')->once();
|
||||
app(WorkflowNotices::class)->shouldReceive('outcome')->once()->andThrow(new RuntimeException('Injected outcome persistence failure'));
|
||||
$this->actingAs($this->head);
|
||||
expect(fn () => app(RequisitionWorkflow::class)->decide($this->head, $r, 'rejected', 'Fundamento de teste.', (string) Str::uuid(), 2))->toThrow(RuntimeException::class);
|
||||
expect($r->fresh()->status)->toBe('pending')->and($r->steps()->sole()->decision)->toBe('pending')
|
||||
->and(DB::table('workflow_notices')->count())->toBe(1)->and(DB::table('audit_logs')->count())->toBe($audits)
|
||||
->and(DB::table('budget_entries')->where('requisition_id', $r->id)->where('kind', 'release')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('controlled routing refresh notifies new eligible people once without changing approval or read history', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
$original = DB::table('workflow_notices')->sole();
|
||||
$this->actingAs($this->head)->post('/requisicoes/avisos/'.$original->id.'/ler')->assertRedirect();
|
||||
$this->head->forceFill(['purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$replacement = User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$before = $r->fresh()->toArray();
|
||||
$readAt = DB::table('workflow_notices')->where('id', $original->id)->value('read_at');
|
||||
$payload = ['operation_key' => (string) Str::uuid(), 'process_version' => 2, 'step_id' => $r->steps()->sole()->id, 'reason' => 'Mudança autorizada da pessoa responsável.'];
|
||||
$url = '/requisicoes/'.$r->id.'/encaminhamento';
|
||||
$this->actingAs($this->actor)->postJson($url, $payload)->assertRedirect();
|
||||
$this->postJson($url, $payload)->assertRedirect();
|
||||
expect($r->fresh()->toArray())->toBe($before)->and(DB::table('workflow_notices')->count())->toBe(2)
|
||||
->and(DB::table('workflow_notices')->where('id', $original->id)->value('read_at'))->toBe($readAt)
|
||||
->and(DB::table('audit_logs')->where('action', 'workflow.routing_refreshed')->count())->toBe(1);
|
||||
$this->postJson($url, [...$payload, 'reason' => 'Conteúdo diferente para a mesma operação.'])->assertUnprocessable();
|
||||
$this->postJson($url, [...$payload, 'operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertUnprocessable();
|
||||
$this->actingAs($replacement)->get('/requisicoes/avisos')->assertInertia(fn (AssertableInertia $p) => $p->where('notices.total', 1)->where('unread', 1));
|
||||
$this->postJson($url, [...$payload, 'operation_key' => (string) Str::uuid()])->assertForbidden();
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['issuer'])->postJson($url, $payload)->assertNotFound();
|
||||
});
|
||||
|
||||
test('routing refresh audit failure rolls back new recipient notices', function () {
|
||||
$this->postJson('/requisicoes', $this->data)->assertRedirect();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter', ['operation_key' => (string) Str::uuid(), 'process_version' => 1])->assertRedirect();
|
||||
User::factory()->create(['tenant_id' => $r->tenant_id, 'department_id' => $r->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected routing audit failure'));
|
||||
expect(fn () => app(WorkflowNotices::class)->refresh($this->actor, $r->id, ['operation_key' => (string) Str::uuid(), 'process_version' => 2,
|
||||
'step_id' => $r->steps()->sole()->id, 'reason' => 'Conferência autorizada do encaminhamento.']))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('workflow_notices')->count())->toBe(1)->and($r->fresh()->status)->toBe('pending');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Services\InventoryCounts;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_prepare', 'inventory_adjust_review']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release']);
|
||||
supplyGrant($this->f['otherReceiver'], ['inventory_lot_release']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'],
|
||||
['code' => 'B', 'expires_on' => today()->addDays(20)->toDateString(), 'quantity' => '4.000']]], 'prepare'));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
});
|
||||
|
||||
function countContext($test): array
|
||||
{
|
||||
return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id);
|
||||
}
|
||||
function countedPayload($test, string $a = '5.000', string $b = '5.000'): array
|
||||
{
|
||||
$lots = countContext($test)['lots'];
|
||||
|
||||
return ['kind' => 'count', 'reference' => 'COUNT-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id,
|
||||
'responsible_id' => $test->f['receiver']->id, 'quantity' => (string) BigDecimal::of($a)->plus($b)->toScale(3), 'reason' => 'Contagem física fictícia repartida por lote.',
|
||||
'ledger_version' => countContext($test)['physical']['version'], 'lot_version' => app(InventoryCounts::class)->source($test->f['stock']->id, $test->f['warehouse']->id)['hash'],
|
||||
'lot_counts' => [['lot_id' => $lots[0]['id'], 'quantity' => $a], ['lot_id' => $lots[1]['id'], 'quantity' => $b]]];
|
||||
}
|
||||
function prepareCount($test, string $a = '5.000', string $b = '5.000'): StockOperation
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], countedPayload($test, $a, $b));
|
||||
}
|
||||
function applyCount($test, StockOperation $count, ?array $payload = null): StockOperation
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['issuer'], $payload ?? ['kind' => 'adjustment', 'reference' => 'ADJ-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $count->warehouse_id, 'stock_item_id' => $count->stock_item_id, 'responsible_id' => $test->f['issuer']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Conferência independente da diferença contada.']);
|
||||
}
|
||||
|
||||
test('a count alone never changes physical stock and a zero-net correction reconciles lot differences', function () {
|
||||
$count = prepareCount($this);
|
||||
expect(StockMovement::query()->count())->toBe(1)->and(countContext($this)['physical']['quantity'])->toBe(10000);
|
||||
$op = applyCount($this, $count);
|
||||
$context = countContext($this);
|
||||
expect(array_column($context['lots'], 'quantity_milli'))->toBe([5000, 5000])->and($context['physical']['quantity'])->toBe(10000)->and($context['reconciled'])->toBeTrue()
|
||||
->and(StockMovement::query()->where('stock_operation_id', $op->id)->orderBy('id')->pluck('quantity_milli')->all())->toBe([-1000, 1000]);
|
||||
expect($op->snapshot['lot_adjustment']['increase_milli'])->toBe(1000)->and($op->snapshot['lot_adjustment']['decrease_milli'])->toBe(1000);
|
||||
});
|
||||
|
||||
test('negative correction can reconcile held or expired material without recording consumption', function () {
|
||||
$this->travel(25)->days();
|
||||
$count = prepareCount($this, '3.000', '0.000');
|
||||
$op = applyCount($this, $count);
|
||||
expect(countContext($this)['physical']['quantity'])->toBe(3000)->and(countContext($this)['usable_milli'])->toBe(0)
|
||||
->and(StockMovement::query()->where('stock_operation_id', $op->id)->pluck('movement_type')->all())->toBe(['adjustment_out']);
|
||||
});
|
||||
|
||||
test('positive corrections quarantine the whole affected lot and preserve the unaffected release', function () {
|
||||
foreach (countContext($this)['lots'] as $lot) {
|
||||
$meta = SupplyRecord::query()->findOrFail($lot['metadata_id']);
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => 0], 'release', $meta));
|
||||
}
|
||||
applyCount($this, prepareCount($this, '7.000', '4.000'));
|
||||
$lots = countContext($this)['lots'];
|
||||
expect($lots[0]['state'])->toBe('held')->and($lots[1]['state'])->toBe('released')->and(countContext($this)['usable_milli'])->toBe(4000);
|
||||
$meta = SupplyRecord::query()->findOrFail($lots[0]['metadata_id']);
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_release']);
|
||||
$input = supplyInput(['lot_id' => $lots[0]['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lots[0]['state_version']], 'release', $meta);
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], $input))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['otherReceiver']);
|
||||
app(InventoryLots::class)->save($this->f['otherReceiver'], $input);
|
||||
expect(countContext($this)['usable_milli'])->toBe(11000);
|
||||
});
|
||||
|
||||
test('found quantities create identity once from the count without fabricating a supplier receipt', function () {
|
||||
$payload = countedPayload($this, '6.000', '4.000');
|
||||
$payload['quantity'] = '12.000';
|
||||
$payload['lot_counts'][] = ['new_key' => (string) Str::uuid(), 'quantity' => '2.000'];
|
||||
$count = app(StockWorkflow::class)->record($this->f['receiver'], $payload);
|
||||
$op = applyCount($this, $count);
|
||||
$found = collect(countContext($this)['lots'])->firstWhere('data.code', null);
|
||||
expect($found['state'])->toBe('unidentified')->and($found['quantity_milli'])->toBe(2000)->and($found['origin']['count_id'])->toBe($count->id)
|
||||
->and(GoodsReceipt::query()->count())->toBe(1)->and($op->snapshot['lot_adjustment']['rows'][2]['lot_id'])->toBe($found['id']);
|
||||
});
|
||||
|
||||
test('count self approval and ungranted adjustment are forbidden even for warehouse operators', function () {
|
||||
$count = prepareCount($this);
|
||||
$payload = ['kind' => 'adjustment', 'reference' => 'SELF', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id,
|
||||
'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['receiver']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Tentativa de ajustar a própria contagem.'];
|
||||
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $payload))->toThrow(HttpException::class);
|
||||
supplyGrant($this->f['receiver'], ['inventory_adjust_review']);
|
||||
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $payload))->toThrow(ValidationException::class);
|
||||
expect(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('quantities, identities and metadata must match the frozen count baseline', function () {
|
||||
$bad = countedPayload($this);
|
||||
$bad['lot_counts'][1]['lot_id'] = $bad['lot_counts'][0]['lot_id'];
|
||||
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $bad))->toThrow(ValidationException::class);
|
||||
$bad = countedPayload($this);
|
||||
$bad['quantity'] = '11.000';
|
||||
expect(fn () => app(StockWorkflow::class)->record($this->f['receiver'], $bad))->toThrow(ValidationException::class);
|
||||
$count = prepareCount($this);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$meta = SupplyRecord::query()->findOrFail(countContext($this)['lots'][0]['metadata_id']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'NEW', 'expires_on' => today()->addDays(11)->toDateString()], 'identify', $meta));
|
||||
expect(fn () => applyCount($this, $count))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('review-only profile can apply the immutable count through HTTP but cannot create a count or issue', function () {
|
||||
$count = prepareCount($this);
|
||||
$payload = ['kind' => 'adjustment', 'reference' => 'HTTP-ADJ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id,
|
||||
'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['issuer']->id, 'count_id' => $count->id, 'reason' => 'Conferência independente pelo perfil próprio.'];
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/operacoes', $payload)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $payload)->assertOk();
|
||||
$this->get('/stock/operacoes')->assertOk();
|
||||
$this->postJson('/stock/operacoes', [...$payload, 'kind' => 'count'])->assertForbidden();
|
||||
expect(StockMovement::query()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('an audit failure rolls back both signed movements, found identities and allocations', function () {
|
||||
$payload = countedPayload($this, '5.000', '4.000');
|
||||
$payload['quantity'] = '11.000';
|
||||
$payload['lot_counts'][] = ['new_key' => (string) Str::uuid(), 'quantity' => '2.000'];
|
||||
$count = app(StockWorkflow::class)->record($this->f['receiver'], $payload);
|
||||
$before = SupplyRecord::query()->count();
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
||||
expect(fn () => applyCount($this, $count))->toThrow(RuntimeException::class);
|
||||
expect(StockMovement::query()->count())->toBe(1)->and(SupplyRecord::query()->count())->toBe($before)->and(countContext($this)['reconciled'])->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_disposition_prepare', 'inventory_lot_review']);
|
||||
supplyGrant($this->f['issuer'], ['procurement', 'inventory_disposition_review', 'inventory_lot_prepare']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
$this->source = $this->receipt->items->first();
|
||||
});
|
||||
|
||||
function dispositionData($test, array $extra = []): array
|
||||
{
|
||||
return ['kind' => 'supplier_return', 'reference' => 'RET-'.Str::uuid(), 'stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'receipt_item_id' => $test->source->id, 'quantity' => '3.000', ...$extra];
|
||||
}
|
||||
function approvedDisposition($test, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
$draft = app(InventoryDispositions::class)->save($test->f['receiver'], supplyInput(dispositionData($test, $extra)));
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(InventoryDispositions::class)->save($test->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
}
|
||||
function executeDisposition($test, SupplyRecord $record, ?array $payload = null): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryDispositions::class)->save($test->f['receiver'], $payload ?? supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada de saída física no ensaio.'], 'execute', $record));
|
||||
}
|
||||
function trackedReturnSource($test): array
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'));
|
||||
$test->actingAs($test->f['receiver']);
|
||||
app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
$payload = receiptPayload($test->f, '5.000');
|
||||
$payload['items'][0]['lots'] = [['code' => 'RETURNABLE', 'expires_on' => today()->addDay()->toDateString()]];
|
||||
$receipt = app(ReceiptWorkflow::class)->confirm($test->f['receiver'], $test->f['order'], $payload);
|
||||
$test->source = $receipt->items->first();
|
||||
|
||||
return collect(app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id)['lots'])->firstWhere('data.code', 'RETURNABLE');
|
||||
}
|
||||
|
||||
test('return reuses accepted receipt and records one physical exit without rewriting acceptance or money', function () {
|
||||
$approved = approvedDisposition($this);
|
||||
expect(StockMovement::query()->count())->toBe(1);
|
||||
$executed = executeDisposition($this, $approved);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(7000)
|
||||
->and($this->source->fresh()->accepted_milli)->toBe(10000)->and(StockOperation::query()->sole()->kind)->toBe('supplier_return')
|
||||
->and($executed->snapshot['source']['receipt']['supplier_id'])->toBe($this->f['order']->supplier_id)
|
||||
->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(3000)
|
||||
->and(Invoice::query()->count())->toBe(0)->and(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('tracked return permits removing expired quarantined material with exact source allocation', function () {
|
||||
$lot = trackedReturnSource($this);
|
||||
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
||||
$this->travel(2)->days();
|
||||
$executed = executeDisposition($this, $approved);
|
||||
$context = app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id);
|
||||
expect($context['reconciled'])->toBeTrue()->and($context['physical']['quantity'])->toBe(12000)->and($context['usable_milli'])->toBe(0)
|
||||
->and(collect($context['lots'])->firstWhere('id', $lot['id'])['quantity_milli'])->toBe(2000)
|
||||
->and(StockMovement::query()->where('stock_operation_id', $executed->data['stock_operation_id'])->sole()->movement_type)->toBe('supplier_return');
|
||||
});
|
||||
|
||||
test('baseline identities cannot pretend to originate from a supplier receipt and foreign sources stay isolated', function () {
|
||||
trackedReturnSource($this);
|
||||
$baseline = collect(app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['lots'])->firstWhere('data.code', null);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(fn () => app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this, ['lot_id' => $baseline['id']]))))->toThrow(ValidationException::class);
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/ocorrencias', supplyInput(dispositionData($this, ['stock_item_id' => $foreign['stock']->id])))->assertNotFound();
|
||||
});
|
||||
|
||||
test('self approval and execution by the reviewer remain forbidden despite accumulated functions', function () {
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this)));
|
||||
supplyGrant($this->f['receiver'], ['inventory_disposition_review']);
|
||||
expect(fn () => app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput([], 'approve', $draft)))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
supplyGrant($this->f['issuer'], ['warehouse']);
|
||||
expect(fn () => app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo fictício para testar independência.'], 'execute', $approved)))->toThrow(ValidationException::class);
|
||||
expect(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('execution retries cannot repeat the exit and cumulative supplier returns respect the original acceptance', function () {
|
||||
$approved = approvedDisposition($this, ['quantity' => '7.000']);
|
||||
$payload = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada fictícia para testar repetição.'], 'execute', $approved);
|
||||
$executed = executeDisposition($this, $approved, $payload);
|
||||
expect(executeDisposition($this, $approved, $payload)->id)->toBe($executed->id)->and(StockMovement::query()->count())->toBe(2);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
expect(fn () => approvedDisposition($this, ['quantity' => '4.000']))->toThrow(ValidationException::class);
|
||||
$second = executeDisposition($this, approvedDisposition($this, ['quantity' => '3.000']));
|
||||
expect($second->state)->toBe('executed')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(10000);
|
||||
});
|
||||
|
||||
test('a later stock shortage blocks an approved disposition without a partial exit', function () {
|
||||
$approved = approvedDisposition($this, ['quantity' => '8.000']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'issue', 'reference' => 'USE', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'quantity' => '3.000', 'reason' => 'Consumo real posterior à preparação.']);
|
||||
expect(fn () => executeDisposition($this, $approved))->toThrow(ValidationException::class);
|
||||
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('approved')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(0);
|
||||
});
|
||||
|
||||
test('write off is a separate physical event and does not count as consumption or supplier return', function () {
|
||||
$lot = trackedReturnSource($this);
|
||||
$approved = approvedDisposition($this, ['kind' => 'write_off', 'lot_id' => $lot['id'], 'quantity' => '2.000']);
|
||||
executeDisposition($this, $approved);
|
||||
expect(StockOperation::query()->sole()->kind)->toBe('write_off')->and(app(InventoryDispositions::class)->returnedForReceipt($this->source->id))->toBe(0)
|
||||
->and(StockMovement::query()->where('movement_type', 'issue')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('an audit failure rolls back stock, partitions and disposition execution together', function () {
|
||||
$lot = trackedReturnSource($this);
|
||||
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
||||
expect(fn () => executeDisposition($this, $approved))->toThrow(RuntimeException::class);
|
||||
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('approved')->and(StockOperation::query()->count())->toBe(0)
|
||||
->and(app(InventoryLots::class)->context($this->f['stock']->id, $this->f['warehouse']->id)['reconciled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('new invoice matching subtracts executed supplier returns without changing existing documents', function () {
|
||||
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
||||
supplyGrant($finance, ['finance']);
|
||||
$this->actingAs($finance);
|
||||
$payload = ['number' => 'FT-BEFORE', 'issued_on' => today()->toDateString(), 'subtotal' => '1000.00', 'tax' => '0.00', 'total' => '1000.00',
|
||||
'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '10.000', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]];
|
||||
$invoice = app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload);
|
||||
executeDisposition($this, approvedDisposition($this));
|
||||
expect($invoice->fresh()->total_minor)->toBe(100000)->and($invoice->fresh()->status)->toBe('pending');
|
||||
$this->actingAs($finance);
|
||||
$payload['number'] = 'FT-AFTER';
|
||||
expect(fn () => app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload))->toThrow(ValidationException::class);
|
||||
$payload['subtotal'] = $payload['total'] = '700.00';
|
||||
$payload['items'][0]['quantity'] = '7.000';
|
||||
expect(app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $payload)->total_minor)->toBe(70000);
|
||||
});
|
||||
|
||||
test('metadata changes after approval and unsupported execution dates require a fresh decision', function () {
|
||||
$lot = trackedReturnSource($this);
|
||||
$approved = approvedDisposition($this, ['lot_id' => $lot['id']]);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(3)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
expect(fn () => executeDisposition($this, $approved))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$returned = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'return', $approved));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$fresh = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(dispositionData($this, ['lot_id' => $lot['id'], 'reference' => $approved->data['reference']]), 'save', $returned));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $fresh));
|
||||
expect(fn () => executeDisposition($this, $approved, supplyInput(['event_on' => today()->addDay()->toDateString(), 'evidence' => 'Comprovativo fictício com data futura.'], 'execute', $approved)))->toThrow(ValidationException::class);
|
||||
expect(StockOperation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('HTTP menu and commands use the same functional permissions without automatic administration writes', function () {
|
||||
$this->actingAs($this->f['receiver'])->get('/stock/ocorrencias')->assertOk();
|
||||
$this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertRedirect();
|
||||
$draft = app(SupplyPlanning::class)->latest('stock_event')->sole();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/ocorrencias', supplyInput([], 'approve', $draft))->assertRedirect();
|
||||
$approved = app(SupplyPlanning::class)->latest('stock_event')->sole();
|
||||
$this->actingAs($this->f['otherReceiver'])->postJson('/stock/ocorrencias', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada para o percurso HTTP real.'], 'execute', $approved))->assertRedirect();
|
||||
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
||||
supplyGrant($admin, ['admin']);
|
||||
$this->actingAs($admin)->get('/stock/ocorrencias')->assertOk();
|
||||
$this->postJson('/stock/ocorrencias', supplyInput(dispositionData($this)))->assertForbidden();
|
||||
expect(app(SupplyPlanning::class)->latest('stock_event')->sole()->state)->toBe('executed');
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryLotAllocation;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_prepare', 'inventory_lot_read']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
});
|
||||
function lotPolicyContext($test): array
|
||||
{
|
||||
return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id);
|
||||
}
|
||||
function initialLotPolicy($test, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'quarantine_on_receipt' => true,
|
||||
'lots' => [['code' => 'BASE', 'expires_on' => today()->addDays(3)->toDateString()]], ...$extra], 'prepare'));
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
}
|
||||
function proposeLotPolicy($test, SupplyRecord $old, array $extra): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['issuer'], supplyInput([...$old->data, ...$extra, 'lots' => []], 'prepare', $old));
|
||||
}
|
||||
function activateLotPolicy($test, SupplyRecord $draft): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
}
|
||||
function releasePolicyBaseline($test): void
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
$lot = lotPolicyContext($test)['lots'][0];
|
||||
app(InventoryLots::class)->save($test->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $test->f['warehouse']->id, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
}
|
||||
function receivePolicyLot($test, array $metadata, array $lineExtra = []): object
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
$payload = receiptPayload($test->f, '2.000');
|
||||
$payload['items'][0] = [...$payload['items'][0], 'lots' => [$metadata], ...$lineExtra];
|
||||
|
||||
return app(ReceiptWorkflow::class)->confirm($test->f['receiver'], $test->f['order'], $payload);
|
||||
}
|
||||
|
||||
test('a proposed policy keeps the approved scope available and shows impact without recreating stock', function () {
|
||||
$active = initialLotPolicy($this);
|
||||
releasePolicyBaseline($this);
|
||||
$before = InventoryLotAllocation::query()->count();
|
||||
$draft = proposeLotPolicy($this, $active, ['minimum_remaining_days' => 5]);
|
||||
expect($draft->snapshot['impact'])->toBe(['before_milli' => 10000, 'after_milli' => 0])
|
||||
->and(app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->id)->toBe($active->id)
|
||||
->and(lotPolicyContext($this)['usable_milli'])->toBe(10000);
|
||||
$this->get('/stock/lotes')->assertInertia(fn (Assert $page) => $page->component('stock/lots')->has('records', 2)->has('contexts.'.$active->record_key));
|
||||
$new = activateLotPolicy($this, $draft);
|
||||
expect(lotPolicyContext($this)['usable_milli'])->toBe(0)->and(InventoryLotAllocation::query()->count())->toBe($before)
|
||||
->and(StockMovement::query()->count())->toBe(1)->and($active->fresh()->data['minimum_remaining_days'])->toBe(0)
|
||||
->and(app(InventoryLots::class)->activeSetups()->count())->toBe(1);
|
||||
$again = activateLotPolicy($this, proposeLotPolicy($this, $new, ['minimum_remaining_days' => 0]));
|
||||
expect($again->snapshot['impact']['after_milli'])->toBe(10000)->and(lotPolicyContext($this)['usable_milli'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('quality or quantity changes after preparing a revision invalidate the frozen impact', function () {
|
||||
$active = initialLotPolicy($this);
|
||||
releasePolicyBaseline($this);
|
||||
$draft = proposeLotPolicy($this, $active, ['minimum_remaining_days' => 1]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$lot = lotPolicyContext($this)['lots'][0];
|
||||
app(InventoryLots::class)->save($this->f['receiver'], supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $this->f['warehouse']->id, 'state_version' => $lot['state_version']], 'hold', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
expect(fn () => activateLotPolicy($this, $draft))->toThrow(ValidationException::class);
|
||||
expect(app(InventoryLots::class)->setup($this->f['stock']->id, $this->f['warehouse']->id)->id)->toBe($active->id);
|
||||
});
|
||||
|
||||
test('preapproved exemption applies only to future compliant identified and sufficiently valid receipts', function () {
|
||||
$active = initialLotPolicy($this, ['quarantine_on_receipt' => false]);
|
||||
expect(lotPolicyContext($this)['usable_milli'])->toBe(0);
|
||||
receivePolicyLot($this, ['code' => 'COMPLETE', 'expires_on' => today()->addDays(10)->toDateString()]);
|
||||
$lot = collect(lotPolicyContext($this)['lots'])->firstWhere('data.code', 'COMPLETE');
|
||||
expect($lot['usable_milli'])->toBe(2000)->and($lot['release_mode'])->toBe('receipt_policy')->and($lot['release_setup_id'])->toBe($active->id)
|
||||
->and($lot['origin']['receipt_setup_id'])->toBe($active->id);
|
||||
receivePolicyLot($this, ['code' => 'UNKNOWN']);
|
||||
receivePolicyLot($this, ['code' => 'RESERVED', 'expires_on' => today()->addDays(10)->toDateString()], ['accepted_quantity' => '1.000', 'quality_status' => 'reservations', 'notes' => 'Reserva de qualidade documentada.']);
|
||||
expect(lotPolicyContext($this)['usable_milli'])->toBe(2000)->and(lotPolicyContext($this)['reconciled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('switching receipt policy affects future arrivals while preserving earlier decisions and their rule version', function () {
|
||||
$active = initialLotPolicy($this, ['quarantine_on_receipt' => false]);
|
||||
receivePolicyLot($this, ['code' => 'EARLIER', 'expires_on' => today()->addDays(10)->toDateString()]);
|
||||
$new = activateLotPolicy($this, proposeLotPolicy($this, $active, ['quarantine_on_receipt' => true]));
|
||||
receivePolicyLot($this, ['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString()]);
|
||||
$lots = collect(lotPolicyContext($this)['lots'])->keyBy('data.code');
|
||||
expect($lots['EARLIER']['release_setup_id'])->toBe($active->id)->and($lots['EARLIER']['usable_milli'])->toBe(2000)
|
||||
->and($lots['LATER']['usable_milli'])->toBe(0)->and($lots['LATER']['origin']['receipt_setup_id'])->toBe($new->id);
|
||||
});
|
||||
|
||||
test('expiry exemptions are explicit and transfers still require destination quarantine under an exempt receipt policy', function () {
|
||||
initialLotPolicy($this, ['quarantine_on_receipt' => false, 'requires_expiry' => false]);
|
||||
receivePolicyLot($this, ['code' => 'UNLIMITED', 'no_expiry' => true]);
|
||||
$destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $destination->id,
|
||||
'requires_expiry' => false, 'minimum_remaining_days' => 0, 'quarantine_on_receipt' => false, 'lots' => []], 'prepare'));
|
||||
activateLotPolicy($this, $draft);
|
||||
app(StockWorkflow::class)->record($this->f['receiver'], ['kind' => 'transfer', 'reference' => 'TRANSFER', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'destination_warehouse_id' => $destination->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '1.000', 'responsible_id' => $this->f['receiver']->id, 'reason' => 'Transferência sujeita a nova qualidade no destino.']);
|
||||
expect(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0)->and(lotPolicyContext($this)['usable_milli'])->toBe(1000);
|
||||
});
|
||||
|
||||
test('a policy author cannot approve the exemption and a revision cannot repartition the baseline again', function () {
|
||||
$active = initialLotPolicy($this);
|
||||
$draft = proposeLotPolicy($this, $active, ['quarantine_on_receipt' => false]);
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_review']);
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class);
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([...$draft->data, 'lots' => [['code' => 'FAKE', 'quantity' => '10.000']]], 'prepare', $draft)))->toThrow(ValidationException::class);
|
||||
expect(InventoryLotAllocation::query()->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryLotAllocation;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\StockWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_read', 'inventory_lot_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_lot_review', 'inventory_lot_release', 'inventory_lot_hold']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse', 'inventory_lot_prepare', 'inventory_lot_release']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->receiptData = receiptPayload($this->f, '10.000');
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptData);
|
||||
});
|
||||
|
||||
function lotSetup($test, array $lots = [], array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => $lots, ...$extra], 'prepare'));
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
}
|
||||
|
||||
function lotContext($test): array
|
||||
{
|
||||
return app(InventoryLots::class)->context($test->f['stock']->id, $test->f['warehouse']->id);
|
||||
}
|
||||
|
||||
function lotDecision($test, array $lot, string $action = 'release', $actor = null, ?int $warehouse = null): SupplyRecord
|
||||
{
|
||||
$actor ??= $test->f['receiver'];
|
||||
$test->actingAs($actor);
|
||||
$metadata = SupplyRecord::query()->findOrFail($lot['metadata_id']);
|
||||
|
||||
return app(InventoryLots::class)->save($actor, supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $warehouse ?? $test->f['warehouse']->id, 'state_version' => $lot['state_version']], $action, $metadata));
|
||||
}
|
||||
|
||||
function lotDestination($test, array $extra = []): Warehouse
|
||||
{
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $test->f['tenant']->id, 'created_by' => $test->f['receiver']->id, 'code' => 'D-'.Str::random(8), 'name' => 'Destino de conferência']);
|
||||
lotSetup($test, [], ['warehouse_id' => $warehouse->id, ...$extra]);
|
||||
|
||||
return $warehouse;
|
||||
}
|
||||
|
||||
function lotIssue($test, string $quantity, array $extra = []): object
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'LOT-'.Str::random(10), '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' => 'Saída fictícia para conferir conservação de lotes.', ...$extra]);
|
||||
}
|
||||
|
||||
test('activation partitions only remaining stock and release never creates stock', function () {
|
||||
lotIssue($this, '2.000');
|
||||
$before = StockMovement::query()->count();
|
||||
lotSetup($this, [['code' => 'LOTE-A', 'expires_on' => today()->addDays(30)->toDateString()]]);
|
||||
expect(lotContext($this))->toMatchArray(['reconciled' => true, 'usable_milli' => 0, 'blocked_milli' => 8000]);
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
lotDecision($this, $lot);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(8000)->and(StockMovement::query()->count())->toBe($before);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptData)->wasRecentlyCreated)->toBeFalse();
|
||||
});
|
||||
|
||||
test('unknown identity stays quarantined and quality decisions require independent metadata authors', function () {
|
||||
lotSetup($this);
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
expect($lot['state'])->toBe('unidentified');
|
||||
expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class);
|
||||
expect(fn () => lotIssue($this, '0.001'))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_release']);
|
||||
expect(fn () => lotDecision($this, $lot, 'release', $this->f['issuer']))->toThrow(ValidationException::class);
|
||||
lotDecision($this, $lot);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('FEFO consumes earliest expiry and preserves the single physical ledger with exact partitions', function () {
|
||||
lotSetup($this, [['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'], ['code' => 'FIRST', 'expires_on' => today()->addDays(1)->toDateString(), 'quantity' => '4.000']]);
|
||||
foreach (lotContext($this)['lots'] as $lot) {
|
||||
lotDecision($this, $lot);
|
||||
}
|
||||
$op = lotIssue($this, '5.000');
|
||||
$lots = collect(lotContext($this)['lots'])->keyBy('data.code');
|
||||
expect($lots['FIRST']['quantity_milli'])->toBe(0)->and($lots['LATER']['quantity_milli'])->toBe(5000)
|
||||
->and(lotContext($this)['reconciled'])->toBeTrue()->and(StockMovement::query()->where('stock_operation_id', $op->id)->count())->toBe(1)
|
||||
->and(InventoryLotAllocation::query()->whereNotNull('stock_movement_id')->count())->toBe(2);
|
||||
$this->travel(2)->days();
|
||||
expect(lotContext($this)['usable_milli'])->toBe(5000);
|
||||
});
|
||||
|
||||
test('tracked receipt is quarantined, reusable retry is exact and expired receipts roll back atomically', function () {
|
||||
lotSetup($this);
|
||||
$data = receiptPayload($this->f, '3.000');
|
||||
$data['items'][0]['lots'] = [['code' => 'NEW', 'expires_on' => today()->addDays(10)->toDateString()]];
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data);
|
||||
expect(lotContext($this)['physical']['quantity'])->toBe(13000)->and(lotContext($this)['usable_milli'])->toBe(0)->and(lotContext($this)['reconciled'])->toBeTrue();
|
||||
expect(app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $data)->id)->toBe($receipt->id);
|
||||
$lot = collect(lotContext($this)['lots'])->firstWhere('data.code', 'NEW');
|
||||
expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class);
|
||||
lotDecision($this, $lot, 'release', $this->f['otherReceiver']);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(3000);
|
||||
$bad = receiptPayload($this->f, '2.000');
|
||||
$bad['items'][0]['lots'] = [['code' => 'EXPIRED', 'expires_on' => today()->subDay()->toDateString()]];
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$before = StockMovement::query()->count();
|
||||
expect(fn () => app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $bad))->toThrow(ValidationException::class);
|
||||
expect(StockMovement::query()->count())->toBe($before)->and(lotContext($this)['physical']['quantity'])->toBe(13000);
|
||||
});
|
||||
|
||||
test('metadata correction invalidates release and cannot rewrite historical quality decisions', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
$release = lotDecision($this, $lot);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
expect(lotContext($this)['usable_milli'])->toBe(0)->and($release->fresh()->state)->toBe('released')->and($release->data['metadata_id'])->toBe($lot['metadata_id']);
|
||||
expect(fn () => lotDecision($this, $lot))->toThrow(ValidationException::class);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('stale baseline and self approval cannot activate while new operations remain unaltered', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'));
|
||||
supplyGrant($this->f['issuer'], ['inventory_lot_review']);
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class);
|
||||
lotIssue($this, '1.000');
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class);
|
||||
expect(InventoryLotAllocation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('expiry and hold remove usable quantity without changing physical stock', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()]], ['minimum_remaining_days' => 1]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
lotDecision($this, lotContext($this)['lots'][0], 'hold');
|
||||
expect(lotContext($this)['usable_milli'])->toBe(0);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$this->travel(2)->days();
|
||||
expect(lotContext($this)['lots'][0]['state'])->toBe('expired')->and(lotContext($this)['physical']['quantity'])->toBe(10000);
|
||||
expect(fn () => lotIssue($this, '0.001'))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('projection expires only unconsumed lot remainder and preserves legacy output without lots', function () {
|
||||
$policy = supplyPolicy($this->f);
|
||||
$policy['daily_milli'] = 2000;
|
||||
$policy['horizon_days'] = 3;
|
||||
$today = today()->toDateString();
|
||||
$projection = app(ReplenishmentProjection::class)->calculate($policy, 10000, $today, [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => $today]]);
|
||||
expect($projection['series'][0]['balance_milli'])->toBe(8000)->and($projection['series'][1]['expired_milli'])->toBe(8000)
|
||||
->and($projection['series'][1]['balance_milli'])->toBe(-2000)->and($projection['series'][2]['expired_milli'])->toBe(0);
|
||||
$legacy = app(ReplenishmentProjection::class)->calculate($policy, 10000, $today);
|
||||
expect($legacy['series'][0])->not->toHaveKey('expired_milli');
|
||||
});
|
||||
|
||||
test('lot menu shares permissions with server, does not grant warehouse operations, and rejects foreign tenant identifiers', function () {
|
||||
$this->actingAs($this->f['issuer'])->get('/stock/lotes')->assertOk();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/operacoes', [])->assertForbidden();
|
||||
$foreign = receiptFixture()['warehouse'];
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/lotes', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $foreign->id, 'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => []], 'prepare'))->assertNotFound();
|
||||
});
|
||||
|
||||
test('tracked stock cannot escape controls through an unpartitioned transfer or count adjustment', function () {
|
||||
lotSetup($this);
|
||||
$destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']);
|
||||
expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class);
|
||||
expect(fn () => lotIssue($this, '9.000', ['kind' => 'count', 'ledger_version' => app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['version']]))->toThrow(ValidationException::class);
|
||||
expect(lotContext($this)['physical']['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('forecast uses usable lots, ignores unqualified arrivals and expires only remaining usable quantities', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$policy = new SupplyRecord(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id]);
|
||||
$data = supplyPolicy($this->f);
|
||||
$data['daily_milli'] = 2000;
|
||||
$data['horizon_days'] = 3;
|
||||
$events = ['reservations' => [], 'receipts' => [['date' => today()->toDateString(), 'quantity' => 500000, 'confirmed' => true]], 'consumed_today' => 0, 'fulfilled_needs' => []];
|
||||
$projection = app(InventoryLots::class)->project($policy, $data, lotContext($this)['physical'], $events, today()->toDateString());
|
||||
expect($projection['physical_milli'])->toBe(10000)->and($projection['usable_milli'])->toBe(10000)
|
||||
->and($projection['series'][0]['incoming_milli'])->toBe(0)->and($projection['series'][1]['expired_milli'])->toBe(8000)
|
||||
->and($projection['lot_warning'])->not->toBeEmpty();
|
||||
lotDecision($this, lotContext($this)['lots'][0], 'hold');
|
||||
$new = app(InventoryLots::class)->project($policy, $data, lotContext($this)['physical'], $events, today()->toDateString());
|
||||
expect($new['usable_milli'])->toBe(0)->and($new['series'][0]['balance_milli'])->toBe(-2000)->and($new['lot_context'])->not->toBe($projection['lot_context']);
|
||||
});
|
||||
|
||||
test('lot allocation database rejects source over-allocation and preserves immutable rows', function () {
|
||||
$active = lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString(), 'quantity' => '6.000'], ['code' => 'B', 'expires_on' => today()->addDays(3)->toDateString(), 'quantity' => '4.000']]);
|
||||
$row = InventoryLotAllocation::query()->firstOrFail();
|
||||
expect(fn () => $row->update(['quantity_milli' => 1]))->toThrow(LogicException::class);
|
||||
expect(fn () => DB::transaction(fn () => DB::table('inventory_lot_allocations')->where('id', $row->id)->update(['quantity_milli' => 1])))->toThrow(QueryException::class);
|
||||
// A third real identity cannot acquire stock beyond the reconciled baseline.
|
||||
$lot = SupplyRecord::query()->create(['tenant_id' => $this->f['tenant']->id, 'kind' => 'lot', 'record_key' => (string) Str::uuid(), 'version' => 1, 'state' => 'registered',
|
||||
'stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'data' => [], 'snapshot' => [], 'reason' => 'Identidade fictícia para testar guarda de origem.',
|
||||
'operation_key' => (string) Str::uuid(), 'input_hash' => str_repeat('0', 64), 'created_by' => $this->f['receiver']->id, 'created_at' => now()]);
|
||||
expect(fn () => DB::transaction(fn () => app(InventoryLots::class)->allocate($lot->id, $this->f['warehouse']->id, 1, null, $active)))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('lot action retries reuse their results and activation cannot be repeated with a new operation', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$input = supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id, 'requires_expiry' => false, 'minimum_remaining_days' => 0,
|
||||
'lots' => [['code' => 'NO-EXPIRY', 'no_expiry' => true]]], 'prepare');
|
||||
$draft = app(InventoryLots::class)->save($this->f['issuer'], $input);
|
||||
expect(app(InventoryLots::class)->save($this->f['issuer'], $input)->id)->toBe($draft->id);
|
||||
$input['reason'] = 'Outra evidência com a mesma chave.';
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['issuer'], $input))->toThrow(ValidationException::class);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$activate = supplyInput([], 'activate', $draft);
|
||||
$active = app(InventoryLots::class)->save($this->f['receiver'], $activate);
|
||||
expect(app(InventoryLots::class)->save($this->f['receiver'], $activate)->id)->toBe($active->id);
|
||||
expect(fn () => app(InventoryLots::class)->save($this->f['receiver'], supplyInput([], 'activate', $draft)))->toThrow(ValidationException::class);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
expect(lotContext($this)['usable_milli'])->toBe(10000)->and(InventoryLotAllocation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('tracked transfer conserves both ledgers and reuses identities and metadata through FEFO', function () {
|
||||
lotSetup($this, [['code' => 'FIRST', 'expires_on' => today()->addDays(2)->toDateString(), 'quantity' => '4.000'], ['code' => 'LATER', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000']]);
|
||||
foreach (lotContext($this)['lots'] as $lot) {
|
||||
lotDecision($this, $lot);
|
||||
}
|
||||
$destination = lotDestination($this);
|
||||
$identities = SupplyRecord::query()->where('kind', 'lot')->count();
|
||||
$operation = lotIssue($this, '5.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
$from = collect(lotContext($this)['lots'])->keyBy('data.code');
|
||||
$to = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id);
|
||||
$received = collect($to['lots'])->keyBy('data.code');
|
||||
expect($from['FIRST']['quantity_milli'])->toBe(0)->and($from['LATER']['quantity_milli'])->toBe(5000)
|
||||
->and($received['FIRST']['quantity_milli'])->toBe(4000)->and($received['LATER']['quantity_milli'])->toBe(1000)
|
||||
->and($received['FIRST']['id'])->toBe($from['FIRST']['id'])->and($received['FIRST']['metadata_id'])->toBe($from['FIRST']['metadata_id'])
|
||||
->and($to['usable_milli'])->toBe(0)->and($to['reconciled'])->toBeTrue()->and(lotContext($this)['reconciled'])->toBeTrue()
|
||||
->and(SupplyRecord::query()->where('kind', 'lot')->count())->toBe($identities)
|
||||
->and(StockMovement::query()->where('stock_operation_id', $operation->id)->count())->toBe(2)
|
||||
->and((int) StockMovement::query()->where('stock_operation_id', $operation->id)->sum('quantity_milli'))->toBe(0)
|
||||
->and($operation->snapshot['lot_partition']['plan'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('transfer custody cannot self release and a repeated arrival holds an already released destination lot', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$destination = lotDestination($this);
|
||||
lotIssue($this, '2.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
$context = fn () => app(InventoryLots::class)->context($this->f['stock']->id, $destination->id);
|
||||
$lot = $context()['lots'][0];
|
||||
expect(fn () => lotDecision($this, $lot, 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id);
|
||||
expect($context()['usable_milli'])->toBe(2000);
|
||||
$stale = $context()['lots'][0];
|
||||
lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
expect($context()['usable_milli'])->toBe(0)->and($context()['physical']['quantity'])->toBe(3000);
|
||||
expect(fn () => lotDecision($this, $stale, 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
// Holding again must not erase the pending custody authors.
|
||||
lotDecision($this, $context()['lots'][0], 'hold', $this->f['receiver'], $destination->id);
|
||||
expect(fn () => lotDecision($this, $context()['lots'][0], 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
lotDecision($this, $context()['lots'][0], 'release', $this->f['otherReceiver'], $destination->id);
|
||||
expect($context()['usable_milli'])->toBe(3000)->and(lotContext($this)['usable_milli'])->toBe(7000);
|
||||
});
|
||||
|
||||
test('transfer retry does not repeat either movement or destination quarantine decision', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$destination = lotDestination($this);
|
||||
$data = ['kind' => 'transfer', 'reference' => 'TR-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['receiver']->id,
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'destination_warehouse_id' => $destination->id, 'stock_item_id' => $this->f['stock']->id, 'quantity' => '3.000', 'reason' => 'Transferência com repetição controlada.'];
|
||||
$first = app(StockWorkflow::class)->record($this->f['receiver'], $data);
|
||||
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['lots'][0];
|
||||
lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(app(StockWorkflow::class)->record($this->f['receiver'], $data)->id)->toBe($first->id)
|
||||
->and(StockMovement::query()->where('stock_operation_id', $first->id)->count())->toBe(2)
|
||||
->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(3000);
|
||||
});
|
||||
|
||||
test('destination requirements apply independently and transfer requires released source stock', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(2)->toDateString()]]);
|
||||
$destination = lotDestination($this, ['minimum_remaining_days' => 5]);
|
||||
expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
$context = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id);
|
||||
expect($context['lots'][0]['state'])->toBe('expired')->and($context['usable_milli'])->toBe(0)->and($context['reconciled'])->toBeTrue();
|
||||
expect(fn () => lotDecision($this, $context['lots'][0], 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('transfer responsible person must also be independent from destination quality release', function () {
|
||||
lotSetup($this, [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$destination = lotDestination($this);
|
||||
lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id, 'responsible_id' => $this->f['otherReceiver']->id]);
|
||||
$lot = app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['lots'][0];
|
||||
expect(fn () => lotDecision($this, $lot, 'release', $this->f['otherReceiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
expect(fn () => lotDecision($this, $lot, 'release', $this->f['receiver'], $destination->id))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('an untracked origin cannot enter a tracked destination without reconciled identities', function () {
|
||||
$destination = lotDestination($this);
|
||||
$before = StockMovement::query()->count();
|
||||
expect(fn () => lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]))->toThrow(ValidationException::class);
|
||||
expect(StockMovement::query()->count())->toBe($before)->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $destination->id)['quantity'])->toBe(0);
|
||||
});
|
||||
|
||||
test('later identification corrections preserve the metadata actually used in the transfer document', function () {
|
||||
lotSetup($this, [['code' => 'ORIGINAL', 'expires_on' => today()->addDays(10)->toDateString()]]);
|
||||
lotDecision($this, lotContext($this)['lots'][0]);
|
||||
$destination = lotDestination($this);
|
||||
$operation = lotIssue($this, '1.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
$lot = lotContext($this)['lots'][0];
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(InventoryLots::class)->save($this->f['issuer'], supplyInput(['code' => 'CORRECTED', 'expires_on' => today()->addDays(9)->toDateString()], 'identify', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
expect($operation->fresh()->snapshot['lot_partition']['plan'][0]['code'])->toBe('ORIGINAL')
|
||||
->and($operation->fresh()->snapshot['lot_partition']['plan'][0]['metadata_id'])->toBe($lot['metadata_id'])
|
||||
->and(lotContext($this)['usable_milli'])->toBe(0)
|
||||
->and(app(InventoryLots::class)->context($this->f['stock']->id, $destination->id)['usable_milli'])->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryCounts;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'inventory_reversal_prepare', 'inventory_disposition_prepare', 'inventory_lot_review', 'inventory_lot_release', 'supply_reserve']);
|
||||
supplyGrant($this->f['issuer'], ['inventory_reversal_review', 'inventory_disposition_review', 'inventory_lot_prepare', 'inventory_adjust_review']);
|
||||
supplyGrant($this->f['otherReceiver'], ['warehouse', 'inventory_lot_release']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->receiptPayload = receiptPayload($this->f, '10.000');
|
||||
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptPayload);
|
||||
$this->receiptItem = $this->receipt->items->first();
|
||||
});
|
||||
function reversalStock($test, string $quantity = '3.000', array $extra = []): StockOperation
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'MOV-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(),
|
||||
'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'responsible_id' => $test->f['receiver']->id,
|
||||
'quantity' => $quantity, 'reason' => 'Movimento fictício para conferir correcção rastreável.', ...$extra]);
|
||||
}
|
||||
function reversalSetup($test, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer']);
|
||||
$draft = app(InventoryLots::class)->save($test->f['issuer'], supplyInput(['stock_item_id' => $test->f['stock']->id, 'warehouse_id' => $test->f['warehouse']->id,
|
||||
'requires_expiry' => true, 'minimum_remaining_days' => 0, 'lots' => [['code' => 'BASE', 'expires_on' => today()->addDays(30)->toDateString()]], ...$extra], 'prepare'));
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryLots::class)->save($test->f['receiver'], supplyInput([], 'activate', $draft));
|
||||
}
|
||||
function reversalContext($test, ?int $warehouse = null): array
|
||||
{
|
||||
return app(InventoryLots::class)->context($test->f['stock']->id, $warehouse ?? $test->f['warehouse']->id);
|
||||
}
|
||||
function reversalRelease($test, int $warehouse, array $lot, $actor = null): SupplyRecord
|
||||
{
|
||||
$actor ??= $test->f['receiver'];
|
||||
$test->actingAs($actor);
|
||||
|
||||
return app(InventoryLots::class)->save($actor, supplyInput(['lot_id' => $lot['id'], 'warehouse_id' => $warehouse, 'state_version' => $lot['state_version']], 'release', SupplyRecord::query()->findOrFail($lot['metadata_id'])));
|
||||
}
|
||||
function approvedReversal($test, array $source): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
$draft = app(InventoryReversals::class)->save($test->f['receiver'], supplyInput(['reference' => 'REV-'.Str::uuid(), ...$source]));
|
||||
$test->actingAs($test->f['issuer']);
|
||||
|
||||
return app(InventoryReversals::class)->save($test->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
}
|
||||
function executeReversal($test, SupplyRecord $approved, ?array $payload = null): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(InventoryReversals::class)->save($test->f['receiver'], $payload ?? supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo da correcção e existência física no ensaio.'], 'execute', $approved));
|
||||
}
|
||||
function reverseSourceOperation($test, StockOperation $operation): SupplyRecord
|
||||
{
|
||||
return executeReversal($test, approvedReversal($test, ['source_kind' => 'operation', 'source_operation_id' => $operation->id]));
|
||||
}
|
||||
|
||||
test('full issue reversal preserves the original and is exactly once even on an execution retry', function () {
|
||||
$original = reversalStock($this);
|
||||
$approved = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]);
|
||||
$payload = supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo fictício para testar repetição do estorno.'], 'execute', $approved);
|
||||
$executed = executeReversal($this, $approved, $payload);
|
||||
expect(executeReversal($this, $approved, $payload)->id)->toBe($executed->id)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000)
|
||||
->and(StockMovement::query()->where('stock_operation_id', $original->id)->sole()->quantity_milli)->toBe(-3000)
|
||||
->and(StockMovement::query()->count())->toBe(3);
|
||||
expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('a corrected issue no longer satisfies a reservation and the same need can use a new actual issue', function () {
|
||||
$reservation = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'quantity_milli' => 3000, 'needed_on' => today()->toDateString(), 'additional' => false, 'reference' => 'NEED-REV']));
|
||||
$issue = reversalStock($this);
|
||||
$fulfilled = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 3000], 'fulfil', $reservation));
|
||||
reverseSourceOperation($this, $issue);
|
||||
$events = app(SupplyCoordination::class)->events($this->f['stock']->id, $this->f['warehouse']->id, today()->toDateString());
|
||||
expect($events['reservations'][0]['quantity'])->toBe(3000)->and($events['fulfilled_needs'])->toBe([])->and($events['consumed_today'])->toBe(0)
|
||||
->and($fulfilled->fresh()->state)->toBe('fulfilled')->and($fulfilled->data['applications'])->toHaveCount(1);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(fn () => app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $issue->id, 'quantity_milli' => 1], 'fulfil', $fulfilled)))->toThrow(ValidationException::class);
|
||||
$nextIssue = reversalStock($this);
|
||||
$again = app(SupplyCoordination::class)->save($this->f['receiver'], 'reservation', supplyInput(['stock_operation_id' => $nextIssue->id, 'quantity_milli' => 3000], 'fulfil', $fulfilled));
|
||||
expect(app(SupplyCoordination::class)->remainingReservation($again))->toBe(0)->and($again->data['applications'])->toHaveCount(2);
|
||||
expect($again->snapshot['effective_allocated_milli'])->toBe(3000)->and($again->snapshot['reversed_operations'])->toContain($issue->id);
|
||||
});
|
||||
|
||||
test('a reversal outside a closed history period invalidates its signature and corrects effective consumption', function () {
|
||||
$day = today()->toDateString();
|
||||
$issue = reversalStock($this);
|
||||
$before = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, $day, $day);
|
||||
$this->travel(2)->days();
|
||||
reverseSourceOperation($this, $issue);
|
||||
$after = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, $day, $day);
|
||||
expect($before['total_milli'])->toBe(3000)->and($after['total_milli'])->toBe(0)->and($after['source_hash'])->not->toBe($before['source_hash']);
|
||||
});
|
||||
|
||||
test('receipt reversal restores authorised quantities without deleting acceptance and a new correction receipt is not a duplicate', function () {
|
||||
$executed = executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]));
|
||||
expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'received_milli'))->toBe(0)
|
||||
->and(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(0)
|
||||
->and($this->receiptItem->fresh()->accepted_milli)->toBe(10000)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(0);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $this->receiptPayload);
|
||||
expect(StockMovement::query()->count())->toBe(2);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(10000)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000)
|
||||
->and($executed->snapshot['source']['receipt']['accepted_milli'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('receipt line reversal removes the full documentary quantities but only the accepted physical entry', function () {
|
||||
$payload = receiptPayload($this->f, '2.000');
|
||||
$payload['items'][0] = [...$payload['items'][0], 'accepted_quantity' => '1.000', 'quality_status' => 'reservations', 'notes' => 'Divergência fictícia da aceitação parcial.'];
|
||||
$receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $payload);
|
||||
executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $receipt->items->first()->id]));
|
||||
expect(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'received_milli'))->toBe(10000)
|
||||
->and(app(ReceiptWorkflow::class)->recordedQuantity($this->f['line']->id, 'accepted_milli'))->toBe(10000)
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('tracked transfer reversal removes original destination lots and quarantines the restored source identity', function () {
|
||||
reversalSetup($this);
|
||||
reversalRelease($this, $this->f['warehouse']->id, reversalContext($this)['lots'][0]);
|
||||
$destination = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']);
|
||||
reversalSetup($this, ['warehouse_id' => $destination->id, 'lots' => []]);
|
||||
$original = reversalStock($this, '2.000', ['kind' => 'transfer', 'destination_warehouse_id' => $destination->id]);
|
||||
$lot = reversalContext($this)['lots'][0];
|
||||
$reversal = reverseSourceOperation($this, $original);
|
||||
expect(reversalContext($this)['physical']['quantity'])->toBe(10000)->and(reversalContext($this)['usable_milli'])->toBe(0)
|
||||
->and(reversalContext($this, $destination->id)['physical']['quantity'])->toBe(0)
|
||||
->and(reversalContext($this)['reconciled'])->toBeTrue()->and(reversalContext($this, $destination->id)['reconciled'])->toBeTrue()
|
||||
->and(StockMovement::query()->where('stock_operation_id', $reversal->data['stock_operation_id'])->count())->toBe(2);
|
||||
reversalRelease($this, $this->f['warehouse']->id, reversalContext($this)['lots'][0], $this->f['otherReceiver']);
|
||||
expect(reversalContext($this)['usable_milli'])->toBe(10000)->and(reversalContext($this)['lots'][0]['id'])->toBe($lot['id']);
|
||||
});
|
||||
|
||||
test('mixed lot adjustments can be reversed without turning corrections into consumption', function () {
|
||||
reversalSetup($this, ['lots' => [['code' => 'A', 'expires_on' => today()->addDays(10)->toDateString(), 'quantity' => '6.000'], ['code' => 'B', 'expires_on' => today()->addDays(20)->toDateString(), 'quantity' => '4.000']]]);
|
||||
$lots = reversalContext($this)['lots'];
|
||||
$count = reversalStock($this, '10.000', ['kind' => 'count', 'ledger_version' => reversalContext($this)['physical']['version'],
|
||||
'lot_version' => app(InventoryCounts::class)->source($this->f['stock']->id, $this->f['warehouse']->id)['hash'], 'lot_counts' => [['lot_id' => $lots[0]['id'], 'quantity' => '5.000'], ['lot_id' => $lots[1]['id'], 'quantity' => '5.000']]]);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$adjustment = app(StockWorkflow::class)->record($this->f['issuer'], ['kind' => 'adjustment', 'reference' => 'ADJ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $count->warehouse_id,
|
||||
'stock_item_id' => $count->stock_item_id, 'responsible_id' => $this->f['issuer']->id, 'quantity' => '0.000', 'count_id' => $count->id, 'reason' => 'Conferência independente da contagem mista.']);
|
||||
reverseSourceOperation($this, $adjustment);
|
||||
expect(array_column(reversalContext($this)['lots'], 'quantity_milli'))->toBe([6000, 4000])->and(reversalContext($this)['physical']['quantity'])->toBe(10000)
|
||||
->and(reversalContext($this)['reconciled'])->toBeTrue()->and(StockMovement::query()->where('movement_type', 'issue')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('restoring an old untracked issue after cutover creates an unidentified held lot without fabricating receipt origin', function () {
|
||||
$original = reversalStock($this);
|
||||
reversalSetup($this);
|
||||
reverseSourceOperation($this, $original);
|
||||
$context = reversalContext($this);
|
||||
$unknown = collect($context['lots'])->firstWhere('data.code', null);
|
||||
expect($context['physical']['quantity'])->toBe(10000)->and($context['usable_milli'])->toBe(0)->and($context['reconciled'])->toBeTrue()
|
||||
->and($unknown['quantity_milli'])->toBe(3000)->and($unknown['origin'])->toHaveKey('reversal_key')->and($unknown['origin'])->not->toHaveKey('receipt_item_id');
|
||||
});
|
||||
|
||||
test('receipt reversal cannot silently associate an old entry with a baseline lot', function () {
|
||||
reversalSetup($this);
|
||||
expect(fn () => approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]))->toThrow(ValidationException::class);
|
||||
expect(reversalContext($this)['physical']['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('a supplier return reversal restores net receipt entitlement but cannot itself be reversed', function () {
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$draft = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['kind' => 'supplier_return', 'reference' => 'RET', 'stock_item_id' => $this->f['stock']->id,
|
||||
'warehouse_id' => $this->f['warehouse']->id, 'receipt_item_id' => $this->receiptItem->id, 'quantity' => '3.000']));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$approved = app(InventoryDispositions::class)->save($this->f['issuer'], supplyInput([], 'approve', $draft));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$returned = app(InventoryDispositions::class)->save($this->f['receiver'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Guia assinada da devolução fictícia.'], 'execute', $approved));
|
||||
expect(app(InventoryDispositions::class)->returnedForReceipt($this->receiptItem->id))->toBe(3000);
|
||||
expect(fn () => approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]))->toThrow(ValidationException::class);
|
||||
$corrected = reverseSourceOperation($this, StockOperation::query()->findOrFail($returned->data['stock_operation_id']));
|
||||
expect(app(InventoryDispositions::class)->returnedForReceipt($this->receiptItem->id))->toBe(0)->and($returned->fresh()->state)->toBe('executed')
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
expect(fn () => approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $corrected->data['stock_operation_id']]))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('an insufficient original lot or warehouse blocks an approved reversal without compensating from another lot', function () {
|
||||
reversalSetup($this);
|
||||
$payload = receiptPayload($this->f, '5.000');
|
||||
$payload['items'][0]['lots'] = [['code' => 'EXACT', 'expires_on' => today()->addDays(30)->toDateString()]];
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], $payload);
|
||||
$lot = collect(reversalContext($this)['lots'])->firstWhere('data.code', 'EXACT');
|
||||
reversalRelease($this, $this->f['warehouse']->id, $lot, $this->f['otherReceiver']);
|
||||
$approved = approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $receipt->items->first()->id]);
|
||||
reversalStock($this, '3.000');
|
||||
expect(fn () => executeReversal($this, $approved))->toThrow(ValidationException::class);
|
||||
expect(reversalContext($this)['physical']['quantity'])->toBe(12000)->and(reversalContext($this)['reconciled'])->toBeTrue()
|
||||
->and(app(InventoryReversals::class)->receiptReversed($receipt->items->first()->id))->toBeFalse();
|
||||
});
|
||||
|
||||
test('approval independence and source isolation apply even with accumulated roles', function () {
|
||||
$original = reversalStock($this);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$draft = app(InventoryReversals::class)->save($this->f['receiver'], supplyInput(['reference' => 'REV', 'source_kind' => 'operation', 'source_operation_id' => $original->id]));
|
||||
supplyGrant($this->f['receiver'], ['inventory_reversal_review']);
|
||||
expect(fn () => app(InventoryReversals::class)->save($this->f['receiver'], supplyInput([], 'approve', $draft)))->toThrow(ValidationException::class);
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['receiver']);
|
||||
$foreignReceipt = app(ReceiptWorkflow::class)->confirm($foreign['receiver'], $foreign['order'], receiptPayload($foreign, '1.000'));
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/estornos', supplyInput(['reference' => 'FOREIGN', 'source_kind' => 'receipt', 'receipt_item_id' => $foreignReceipt->items->first()->id]))->assertNotFound();
|
||||
});
|
||||
|
||||
test('a reviewer cannot execute their own decision and duplicate approved plans cannot reverse the original twice', function () {
|
||||
$original = reversalStock($this);
|
||||
$first = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]);
|
||||
$second = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]);
|
||||
supplyGrant($this->f['issuer'], ['warehouse']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
expect(fn () => app(InventoryReversals::class)->save($this->f['issuer'], supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Tentativa de executar a própria conferência.'], 'execute', $first)))->toThrow(ValidationException::class);
|
||||
executeReversal($this, $first);
|
||||
expect(fn () => executeReversal($this, $second))->toThrow(ValidationException::class);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('a final audit failure rolls back all reversal movements and leaves the original approval intact', function () {
|
||||
$original = reversalStock($this);
|
||||
$approved = approvedReversal($this, ['source_kind' => 'operation', 'source_operation_id' => $original->id]);
|
||||
$before = StockMovement::query()->count();
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
||||
expect(fn () => executeReversal($this, $approved))->toThrow(RuntimeException::class);
|
||||
expect(StockMovement::query()->count())->toBe($before)->and($approved->fresh()->state)->toBe('approved')
|
||||
->and(app(InventoryReversals::class)->operationReversed($original->id))->toBeFalse();
|
||||
});
|
||||
|
||||
test('HTTP permissions and source views expose the correction without changing original documents', function () {
|
||||
$original = reversalStock($this);
|
||||
$this->postJson('/stock/estornos', supplyInput(['reference' => 'HTTP-REV', 'source_kind' => 'operation', 'source_operation_id' => $original->id]))->assertRedirect();
|
||||
$draft = app(SupplyPlanning::class)->latest('stock_reversal')->sole();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/estornos', supplyInput([], 'approve', $draft))->assertRedirect();
|
||||
$approved = app(SupplyPlanning::class)->latest('stock_reversal')->sole();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/estornos', supplyInput(['event_on' => today()->toDateString(), 'evidence' => 'Comprovativo para o percurso HTTP do estorno.'], 'execute', $approved))->assertRedirect();
|
||||
$this->get('/stock/estornos')->assertOk();
|
||||
$this->get('/stock/operacoes')->assertOk();
|
||||
$this->actingAs($this->f['issuer'])->get('/stock/ocorrencias')->assertOk();
|
||||
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
||||
supplyGrant($admin, ['admin']);
|
||||
$this->actingAs($admin)->get('/stock/estornos')->assertOk();
|
||||
$this->postJson('/stock/estornos', supplyInput(['reference' => 'NO-WRITE', 'source_kind' => 'operation', 'source_operation_id' => $original->id]))->assertForbidden();
|
||||
});
|
||||
|
||||
test('voided receipt matching blocks new invoices while preserving invoice facts already registered', function () {
|
||||
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id]);
|
||||
supplyGrant($finance, ['finance']);
|
||||
$this->actingAs($finance);
|
||||
$data = ['number' => 'OLD-INVOICE', 'issued_on' => today()->toDateString(), 'subtotal' => '1000.00', 'tax' => '0.00', 'total' => '1000.00',
|
||||
'items' => [['purchase_order_item_id' => $this->f['line']->id, 'quantity' => '10.000', 'unit' => 'cx', 'unit_price' => '100.00', 'tax_rate' => '0']]];
|
||||
$invoice = app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $data);
|
||||
$snapshot = $invoice->fresh(['items'])->toArray();
|
||||
executeReversal($this, approvedReversal($this, ['source_kind' => 'receipt', 'receipt_item_id' => $this->receiptItem->id]));
|
||||
expect($invoice->fresh(['items'])->toArray())->toBe($snapshot)->and(app(InventoryReversals::class)->financialNotices())->toHaveCount(1);
|
||||
$this->actingAs($finance);
|
||||
$data['number'] = 'NEW-INVOICE';
|
||||
expect(fn () => app(InvoiceWorkflow::class)->register($finance, $this->f['order'], $data))->toThrow(ValidationException::class);
|
||||
$this->get('/facturas')->assertOk();
|
||||
$this->actingAs($this->f['receiver'])->get('/recepcoes/'.$this->receipt->id)->assertOk();
|
||||
});
|
||||
@@ -523,3 +523,147 @@ test('the person concluding technical clarification cannot approve the hiring se
|
||||
hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertForbidden();
|
||||
$this->get('/pessoas')->assertInertia(fn (Assert $page) => $page->where('workdesk.counts.approvals', 0));
|
||||
});
|
||||
|
||||
test('people reports reuse only visible processes and export filtered facts without private fields', function () {
|
||||
$record = hiringCreate($this);
|
||||
$this->actingAs($this->approver)->postJson('/pessoas', [...$this->body, 'operation_key' => (string) Str::uuid(), 'title' => 'PROCESSO RESERVADO', 'kind' => 'staff'])->assertRedirect();
|
||||
$this->actingAs($this->preparer)->get('/pessoas/relatorios')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('people/reports')->where('processes.total', 1)->has('summary', 1)
|
||||
->where('processes.data.0.id', $record->id)->missing('processes.data.0.rules')->missing('processes.data.0.contract'));
|
||||
$csv = $this->get('/pessoas/relatorios.csv')->assertOk();
|
||||
expect($csv->streamedContent())->toContain('PES-'.$record->id)->not->toContain('PROCESSO RESERVADO');
|
||||
expect($csv->headers->get('Cache-Control'))->toContain('no-store');
|
||||
$this->get('/pessoas/relatorios?kind=staff')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0)->has('summary', 0));
|
||||
$this->get('/pessoas/relatorios?status=selected')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0));
|
||||
$this->get('/pessoas/relatorios?search=PES-'.$record->id)->assertInertia(fn (Assert $page) => $page->where('processes.total', 1));
|
||||
DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->preparer->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/pessoas/relatorios')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0));
|
||||
expect($this->get('/pessoas/relatorios.csv')->streamedContent())->not->toContain('PES-'.$record->id);
|
||||
});
|
||||
|
||||
test('people report dates and csv protect scope and spreadsheet cells', function () {
|
||||
$this->body['title'] = '=FORMULA DE ENSAIO';
|
||||
$record = hiringCreate($this);
|
||||
$csv = $this->get('/pessoas/relatorios.csv')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain("'=FORMULA DE ENSAIO");
|
||||
$this->get('/pessoas/relatorios?from='.today()->addDay()->toDateString())->assertInertia(fn (Assert $page) => $page->where('processes.total', 0));
|
||||
$this->getJson('/pessoas/relatorios?from=2026-11-02&to=2026-11-01')->assertUnprocessable();
|
||||
$foreign = User::factory()->create(['tenant_id' => Tenant::query()->create(['name' => 'Empresa distinta'])->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($foreign)->get('/pessoas/relatorios')->assertInertia(fn (Assert $page) => $page->where('processes.total', 0));
|
||||
expect($this->get('/pessoas/relatorios.csv')->streamedContent())->not->toContain('PES-'.$record->id);
|
||||
});
|
||||
|
||||
test('application report reuses eligible assessments and preserves approved selection counts without private content', function () {
|
||||
$record = hiringAssessment($this);
|
||||
$application = DB::table('people_applications')->sole();
|
||||
$url = '/pessoas/relatorios?view=applications';
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where('applicationMetrics.'.$record->id.'.registered', 1)
|
||||
->where('applicationMetrics.'.$record->id.'.evaluation_pending', 1)
|
||||
->where('applicationMetrics.'.$record->id.'.evaluation_unstarted', 1));
|
||||
hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [
|
||||
['score' => '8', 'evidence' => 'Evidência fictícia de competências.'], ['score' => '7', 'evidence' => 'Evidência fictícia de experiência.'],
|
||||
]])->assertRedirect();
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where('applicationMetrics.'.$record->id.'.evaluation_complete', 1)
|
||||
->where('applicationMetrics.'.$record->id.'.evaluation_pending', 0)
|
||||
->missing('applicationMetrics.'.$record->id.'.reviews')->missing('applicationMetrics.'.$record->id.'.name'));
|
||||
hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect();
|
||||
hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect();
|
||||
DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->reviewer->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where('applicationMetrics.'.$record->id.'.basis', 'selection_snapshot')
|
||||
->where('applicationMetrics.'.$record->id.'.evaluation_complete', 1));
|
||||
$this->actingAs($this->approver)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where('applicationMetrics.'.$record->id.'.portal_received', null));
|
||||
expect($this->get('/pessoas/relatorios.csv?view=applications')->streamedContent())->toContain('Sem acesso')->toContain('Selecção preservada')->not->toContain($application->name);
|
||||
});
|
||||
|
||||
test('selection report follows return approval and draft while restricting contract metadata', function (string $kind) {
|
||||
$record = hiringAssessment($this, $kind);
|
||||
$application = DB::table('people_applications')->sole();
|
||||
$url = '/pessoas/relatorios?view=selections';
|
||||
$prefix = 'selectionMetrics.'.$record->id.'.';
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where($prefix.'selection_state', 'Sem proposta de selecção')->where($prefix.'contract_reference', null));
|
||||
hiringCommand($this, $this->reviewer, $record, 'evaluate', ['application_id' => $application->id, 'answers' => [
|
||||
['score' => '8', 'evidence' => 'Evidência fictícia de competências.'], ['score' => '7', 'evidence' => 'Evidência fictícia de experiência.'],
|
||||
]])->assertRedirect();
|
||||
hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect();
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Proposta por conferir'));
|
||||
hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'returned'])->assertRedirect();
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Proposta devolvida'));
|
||||
hiringCommand($this, $this->preparer, $record, 'select', ['application_id' => $application->id])->assertRedirect();
|
||||
hiringCommand($this, $this->approver, $record, 'confirm', ['outcome' => 'approved'])->assertRedirect();
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'selection_state', 'Selecção aprovada')->where($prefix.'pending', 'Preparar minuta contratual'));
|
||||
hiringCommand($this, $this->preparer, $record, 'contract', ['reference' => '=MINUTA-RESERVADA', 'starts_on' => today()->toDateString(), 'terms' => 'TERMOS-CONTRATUAIS-RESERVADOS-NAO-EXPORTAR'])->assertRedirect();
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->where($prefix.'contract_reference', '=MINUTA-RESERVADA')->missing($prefix.'terms'));
|
||||
$csv = $this->get('/pessoas/relatorios.csv?view=selections')->streamedContent();
|
||||
expect($csv)->toContain("'=MINUTA-RESERVADA")->not->toContain('TERMOS-CONTRATUAIS-RESERVADOS');
|
||||
$this->actingAs($this->approver)->get($url)->assertInertia(fn (Assert $page) => $page
|
||||
->where($prefix.'contract_reference', 'Sem acesso')->where($prefix.'contract_starts_on', 'Sem acesso'));
|
||||
expect($this->get('/pessoas/relatorios.csv?view=selections')->streamedContent())->not->toContain('MINUTA-RESERVADA');
|
||||
DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->approver->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get($url)->assertInertia(fn (Assert $page) => $page->where('selectionMetrics', [])->where('processes.total', 0));
|
||||
})->with(['service', 'staff']);
|
||||
|
||||
test('deadline report distinguishes process alerts from personal tasks and respects expired access', function () {
|
||||
$record = hiringCreate($this);
|
||||
$this->travel(2)->days();
|
||||
$url = '/pessoas/relatorios?view=deadlines';
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $p) => $p
|
||||
->where('deadlineReport.tasks', 0)->where('deadlineReport.alerts', 1)->where('deadlineReport.overdue_items', 1)
|
||||
->where('deadlineReport.items.0.days_overdue', 2)
|
||||
->where('deadlineReport.items.0.responsibility', 'Alerta do processo; sem atribuição pessoal'));
|
||||
expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->toContain('PES-'.$record->id)->toContain('Dias de atraso');
|
||||
DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $this->preparer->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get($url)->assertInertia(fn (Assert $p) => $p->has('deadlineReport.items', 0)->where('deadlineReport.overdue_items', 0));
|
||||
expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->not->toContain('PES-'.$record->id);
|
||||
});
|
||||
|
||||
test('deadline report reuses only the current actors work and never invents evaluation deadlines', function () {
|
||||
$record = hiringAssessment($this);
|
||||
$url = '/pessoas/relatorios?view=deadlines';
|
||||
$this->actingAs($this->reviewer)->get($url)->assertInertia(fn (Assert $p) => $p
|
||||
->where('deadlineReport.tasks', 1)->where('deadlineReport.without_deadline', 1)->where('deadlineReport.overdue_items', 0)
|
||||
->where('deadlineReport.items.0.due_on', null)->where('deadlineReport.items.0.days_overdue', null)
|
||||
->where('deadlineReport.items.0.responsibility', 'Minha intervenção autorizada')
|
||||
->missing('deadlineReport.items.0.answers')->missing('deadlineReport.items.0.score_minor'));
|
||||
expect($this->get('/pessoas/relatorios.csv?view=deadlines')->streamedContent())->toContain('Sem prazo definido')->toContain('Não determinável');
|
||||
$this->actingAs($this->preparer)->get($url)->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 0));
|
||||
});
|
||||
|
||||
test('deadline windows distinguish future today and overdue calendar dates', function () {
|
||||
$this->body['closes_on'] = today()->addDays(2)->toDateString();
|
||||
hiringCreate($this);
|
||||
$this->actingAs($this->reviewer);
|
||||
$base = '/pessoas/relatorios?view=deadlines&deadline_window=';
|
||||
$this->get($base.'future')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.approvals', 1));
|
||||
$this->get($base.'today')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0));
|
||||
$this->travel(2)->days();
|
||||
$this->get($base.'today')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.items.0.days_overdue', 0));
|
||||
$this->get($base.'overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0));
|
||||
$this->travel(1)->days();
|
||||
$this->get($base.'overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 1)->where('deadlineReport.items.0.days_overdue', 1));
|
||||
$this->get($base.'undated')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0));
|
||||
$this->getJson($base.'invalid')->assertUnprocessable();
|
||||
$this->getJson('/pessoas/relatorios?view=deadlines&deadline_page=0')->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('deadline totals cover all matching occurrences while screen pagination and csv keep the same scope', function () {
|
||||
$record = hiringCreate($this);
|
||||
hiringCommand($this, $this->reviewer, $record, 'open')->assertRedirect();
|
||||
foreach (range(1, 30) as $number) {
|
||||
hiringCommand($this, $this->preparer, $record, 'apply', ['operation_key' => (string) Str::uuid(), 'name' => 'Pessoa fictícia '.$number,
|
||||
'reference' => 'CAND-'.$number, 'summary' => 'Experiência fictícia pertinente ao processo.', 'received_on' => today()->toDateString()])->assertRedirect();
|
||||
}
|
||||
hiringCommand($this, $this->preparer, $record, 'close')->assertRedirect();
|
||||
$this->actingAs($this->reviewer);
|
||||
$url = '/pessoas/relatorios?view=deadlines&deadline_window=undated';
|
||||
$this->get($url)->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 30)->where('deadlineReport.total_items', 30)
|
||||
->where('deadlineReport.matching_processes', 1)->has('deadlineReport.items', 25)->where('deadlineReport.pagination.last_page', 2));
|
||||
$this->get($url.'&deadline_page=2')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.tasks', 30)->has('deadlineReport.items', 5));
|
||||
$csv = $this->get('/pessoas/relatorios.csv?view=deadlines&deadline_window=undated&deadline_page=2')->streamedContent();
|
||||
expect(substr_count($csv, 'Avaliar candidatura #'))->toBe(30);
|
||||
$this->get('/pessoas/relatorios?view=deadlines&deadline_window=overdue')->assertInertia(fn (Assert $p) => $p->where('deadlineReport.total_items', 0));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Carbon\CarbonImmutable;
|
||||
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->travelTo(CarbonImmutable::parse('2026-09-30 12:00:00'));
|
||||
$this->f = receiptFixture('1500.000');
|
||||
supplyGrant($this->f['issuer'], ['procurement', 'supply_prepare', 'supply_history_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'supply_review', 'supply_history_review', 'supply_reserve']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1200.000'));
|
||||
historyIssue($this, '2026-10-10', '100.000');
|
||||
historyIssue($this, '2026-10-20', '200.000');
|
||||
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
});
|
||||
|
||||
function historyIssue($test, string $date, string $quantity): object
|
||||
{
|
||||
$test->travelTo(CarbonImmutable::parse($date.' 12:00:00'));
|
||||
$test->actingAs($test->f['receiver']);
|
||||
|
||||
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'HIST-'.Str::random(10), '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' => 'Consumo fictício para testar o período histórico.']);
|
||||
}
|
||||
|
||||
function historyData(array $f): array
|
||||
{
|
||||
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purpose' => 'internal', 'period_from' => '2026-10-01', 'period_until' => '2026-10-30', 'quality' => 'complete', 'pattern' => 'regular'];
|
||||
}
|
||||
|
||||
function approvedHistory($test, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/history', supplyInput([...historyData($test->f), ...$extra]))->assertRedirect();
|
||||
$draft = app(SupplyPlanning::class)->latest('history')->sole();
|
||||
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertRedirect();
|
||||
|
||||
return app(SupplyPlanning::class)->latest('history')->sole();
|
||||
}
|
||||
|
||||
function historyPolicy($test, SupplyRecord $history, array $extra = []): SupplyRecord
|
||||
{
|
||||
$data = [...supplyPolicy($test->f), 'purpose' => 'internal', 'safety_milli' => 600000, 'minimum_stock_milli' => 600000, 'target_milli' => 900000, 'maximum_milli' => 1200000,
|
||||
'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 30, 'history_max_age_days' => 90, 'daily_milli' => 999999, ...$extra];
|
||||
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput($data))->assertRedirect();
|
||||
$draft = app(SupplyPlanning::class)->latest('policy')->sole();
|
||||
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $draft))->assertRedirect();
|
||||
|
||||
return app(SupplyPlanning::class)->latest('policy')->sole();
|
||||
}
|
||||
|
||||
test('historical mean includes all calendar days and policy reuses the approved period without accepting forged totals', function () {
|
||||
$history = approvedHistory($this, ['total_milli' => 999999, 'daily_mean_milli' => '999']);
|
||||
expect($history->snapshot['analysis']['total_milli'])->toBe(300000)->and($history->snapshot['analysis']['days'])->toBe(30)
|
||||
->and($history->snapshot['analysis']['daily_mean_milli'])->toBe('10000.000000')->and($history->snapshot['analysis']['active_days'])->toBe(2);
|
||||
$policy = historyPolicy($this, $history);
|
||||
expect($policy->data['daily_milli'])->toBeNull()->and($policy->data['history_version'])->toBe($history->version);
|
||||
$effective = app(SupplyHistory::class)->resolve($policy);
|
||||
$projection = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString());
|
||||
expect($projection['method'])->toBe('historical_mean_calendar_days')->and($projection['daily_milli'])->toBe('10000.000000')->and($projection['series'][0]['demand_milli'])->toBe(10000);
|
||||
$this->actingAs($this->f['issuer'])->get('/compras/fornecimento?view=calendar')->assertInertia(fn (Assert $page) => $page->where('projections.'.$policy->record_key.'.forecast_history.available', true));
|
||||
});
|
||||
|
||||
test('transfers adjustments counts and receptions affect stock but never inflate consumption', function () {
|
||||
$this->travelTo(CarbonImmutable::parse('2026-10-22 12:00:00'));
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$other = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'HIST-2', 'name' => 'Outro local do ensaio']);
|
||||
$base = ['responsible_id' => $this->f['receiver']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'reason' => 'Operação não representa consumo na análise.'];
|
||||
app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'transfer', 'reference' => 'HIST-TRANS', 'idempotency_key' => (string) Str::uuid(), 'destination_warehouse_id' => $other->id, 'quantity' => '10.000']);
|
||||
$version = app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['version'];
|
||||
$count = app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'count', 'reference' => 'HIST-COUNT', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '905.000', 'ledger_version' => $version]);
|
||||
app(StockWorkflow::class)->record($this->f['receiver'], [...$base, 'kind' => 'adjustment', 'reference' => 'HIST-ADJUST', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '905.000', 'count_id' => $count->id]);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '50.000'));
|
||||
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
||||
$analysis = app(SupplyHistory::class)->analyse($this->f['stock']->id, $this->f['warehouse']->id, '2026-10-01', '2026-10-30');
|
||||
expect($analysis['total_milli'])->toBe(300000)->and($analysis['movement_types']['issue'])->toBe(2)->and($analysis['movement_types']['transfer_out'])->toBe(1)
|
||||
->and($analysis['movement_types']['adjustment'])->toBe(1)->and($analysis['movement_types']['receipt_in'])->toBe(1);
|
||||
});
|
||||
|
||||
test('unknown history remains unavailable whereas reviewed zero consumption is a genuine zero and known needs remain', function () {
|
||||
$history = approvedHistory($this, ['period_from' => '2026-10-21']);
|
||||
$policy = historyPolicy($this, $history, ['history_min_days' => 10]);
|
||||
$effective = app(SupplyHistory::class)->resolve($policy);
|
||||
$zero = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString());
|
||||
$unknown = app(ReplenishmentProjection::class)->calculate([...supplyPolicy($this->f), 'daily_milli' => null], 900000, today()->toDateString());
|
||||
expect($zero['available'])->toBeTrue()->and($zero['daily_milli'])->toBe('0.000000')->and($zero['quantity_milli'])->toBe(0)->and($unknown['available'])->toBeFalse();
|
||||
$known = app(ReplenishmentProjection::class)->calculate($effective['data'], 900000, today()->toDateString(), [['date' => today()->addDay()->toDateString(), 'quantity' => 350000, 'additional' => true]]);
|
||||
expect($known['risk_date'])->toBe(today()->addDay()->toDateString())->and($known['quantity_milli'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('fractional milliunit average is projected cumulatively instead of becoming false zero', function () {
|
||||
historyIssue($this, '2026-10-28', '0.001');
|
||||
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
||||
$history = approvedHistory($this, ['period_from' => '2026-10-28']);
|
||||
$policy = historyPolicy($this, $history, ['history_min_days' => 3]);
|
||||
$data = app(SupplyHistory::class)->resolve($policy)['data'];
|
||||
$projection = app(ReplenishmentProjection::class)->calculate([...$data, 'horizon_days' => 3], 900000, today()->toDateString());
|
||||
expect($projection['daily_milli'])->toBe('0.333333')->and(array_sum(array_column($projection['series'], 'demand_milli')))->toBe(1);
|
||||
});
|
||||
|
||||
test('independent confirmation is mandatory and late historical entries invalidate rather than overwrite an approved analysis', function () {
|
||||
$input = supplyInput(historyData($this->f));
|
||||
$this->postJson('/compras/fornecimento/history', $input)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/history', $input)->assertRedirect();
|
||||
$draft = app(SupplyPlanning::class)->latest('history')->sole();
|
||||
supplyGrant($this->f['issuer'], ['supply_history_review']);
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertUnprocessable();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput(['confirm_scope' => true], 'approve', $draft))->assertRedirect();
|
||||
$approved = app(SupplyPlanning::class)->latest('history')->sole();
|
||||
$policy = historyPolicy($this, $approved);
|
||||
historyIssue($this, '2026-10-25', '1.000');
|
||||
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
||||
$result = app(SupplyHistory::class)->resolve($policy);
|
||||
expect($result['history']['available'])->toBeFalse()->and($result['data']['daily_milli'])->toBeNull()->and($approved->fresh()->snapshot['analysis']['total_milli'])->toBe(300000);
|
||||
});
|
||||
|
||||
test('insufficient coverage and nonregular patterns cannot silently feed a simple average', function (string $quality, string $pattern) {
|
||||
$history = approvedHistory($this, ['quality' => $quality, 'pattern' => $pattern]);
|
||||
$data = [...supplyPolicy($this->f), 'purpose' => 'internal', 'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 30, 'history_max_age_days' => 90];
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput($data))->assertUnprocessable();
|
||||
})->with([['unknown', 'regular'], ['incomplete', 'regular'], ['stockout', 'regular'], ['complete', 'seasonal'], ['complete', 'intermittent']]);
|
||||
|
||||
test('minimum coverage purpose age and suspension remain explicit policy conditions', function () {
|
||||
$history = approvedHistory($this);
|
||||
$data = [...supplyPolicy($this->f), 'forecast_method' => 'historical', 'history_key' => $history->record_key, 'history_min_days' => 31, 'history_max_age_days' => 90];
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput([...$data, 'purpose' => 'internal']))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput([...$data, 'history_min_days' => 30]))->assertUnprocessable();
|
||||
$policy = historyPolicy($this, $history);
|
||||
$this->travelTo(CarbonImmutable::parse('2027-02-01 12:00:00'));
|
||||
expect(app(SupplyHistory::class)->resolve($policy)['history']['available'])->toBeFalse();
|
||||
$this->travelTo(CarbonImmutable::parse('2026-11-01 12:00:00'));
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/history', supplyInput([], 'suspend', $history))->assertRedirect();
|
||||
expect(app(SupplyHistory::class)->resolve($policy)['history']['available'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('historical dates must be closed and the source ledger never crosses companies or changes through analysis', function () {
|
||||
$before = StockMovement::query()->count();
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput([...historyData($this->f), 'period_until' => today()->toDateString()]))->assertUnprocessable();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/history', supplyInput([...historyData($this->f), 'stock_item_id' => $other['stock']->id]))->assertNotFound();
|
||||
$this->get('/compras/fornecimento?view=history')->assertInertia(fn (Assert $page) => $page->component('supply/history')->where('canPrepare', true));
|
||||
expect(StockMovement::query()->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('suggestions freeze the historical period and a suspended source cannot be used to convert an older approval', function () {
|
||||
$history = approvedHistory($this);
|
||||
$policy = historyPolicy($this, $history);
|
||||
app(RequisitionCatalogue::class)->save($this->f['receiver'], $this->f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($this->f['stock']),
|
||||
'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Reutilização do catálogo no percurso histórico.']);
|
||||
supplyGrant($this->f['issuer'], ['supply_suggest_prepare', 'supply_suggest_convert', 'requester']);
|
||||
supplyGrant($this->f['receiver'], ['supply_suggest_review']);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $policy->record_key, 'unit_price' => '10.00']))->assertRedirect();
|
||||
$suggestion = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect($suggestion->snapshot['forecast_history']['version'])->toBe($history->version)->and($suggestion->snapshot['forecast_history']['analysis']['total_milli'])->toBe(300000);
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $suggestion))->assertRedirect();
|
||||
$approved = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
$this->postJson('/compras/fornecimento/history', supplyInput([], 'suspend', $history))->assertRedirect();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
||||
expect($approved->fresh()->snapshot['forecast_history']['version'])->toBe($history->version)->and($approved->fresh()->state)->toBe('approved');
|
||||
});
|
||||
|
||||
test('replacement policy can reuse all approved parameters by reference while activation remains independent and unique', function () {
|
||||
$history = approvedHistory($this);
|
||||
$source = historyPolicy($this, $history);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(['predecessor_key' => $source->record_key, 'predecessor_version' => $source->version]))->assertRedirect();
|
||||
$copy = app(SupplyPlanning::class)->latest('policy')->where('record_key', '!=', $source->record_key)->sole();
|
||||
expect($copy->data['target_milli'])->toBe($source->data['target_milli'])->and($copy->data['history_key'])->toBe($history->record_key)->and($copy->data['predecessor_version'])->toBe($source->version);
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'suspend', $source))->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $copy))->assertRedirect();
|
||||
expect(app(SupplyPlanning::class)->latest('policy')->where('state', 'approved')->count())->toBe(1)->and($source->fresh()->data['target_milli'])->toBe(900000);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Services\ReplenishmentProjection;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
test('calendar keeps stock risk but withdraws deadlines when the reused agreement expires or supplier changes', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['supply_prepare']);
|
||||
supplyGrant($f['receiver'], ['supply_review']);
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$this->actingAs($f['issuer']);
|
||||
$draft = $planning->save($f['issuer'], 'agreement', supplyInput([
|
||||
'stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
|
||||
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addDay()->toDateString(),
|
||||
'origin' => 'local', 'currency' => 'MZN', 'price_minor' => null, 'lead_days' => 4, 'reference' => 'Prazo documentado de ensaio',
|
||||
]));
|
||||
$this->actingAs($f['receiver']);
|
||||
$agreement = $planning->save($f['receiver'], 'agreement', supplyInput([], 'approve', $draft));
|
||||
$this->actingAs($f['issuer']);
|
||||
$draft = $planning->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'agreement_key' => $agreement->record_key]));
|
||||
$this->actingAs($f['receiver']);
|
||||
$policy = $planning->save($f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
||||
$current = $planning->calendar($policy, today()->toDateString());
|
||||
expect($current['available'])->toBeTrue()->and($current['request_date'])->not->toBeNull();
|
||||
$expired = $planning->calendar($policy, today()->addDays(2)->toDateString());
|
||||
expect($expired['available'])->toBeTrue()->and($expired['lead_time_available'])->toBeFalse()
|
||||
->and($expired['request_date'])->toBeNull()->and($expired['order_date'])->toBeNull()->and($expired['reorder_point_milli'])->toBeNull();
|
||||
$supplier = Supplier::query()->findOrFail($f['order']->supplier_id);
|
||||
$supplier->update(['version' => $supplier->version + 1]);
|
||||
expect($planning->calendar($policy, today()->toDateString())['lead_time_available'])->toBeFalse();
|
||||
$f['stock']->update(['unit' => 'UNIDADE ALTERADA']);
|
||||
expect($planning->calendar($policy, today()->toDateString())['available'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('replenishment reproduces examples A B and C without counting reservations or late arrivals twice', function () {
|
||||
$p = ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000, 'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
|
||||
$engine = app(ReplenishmentProjection::class);
|
||||
$a = $engine->calculate($p, 120000, '2026-11-01');
|
||||
expect($a['risk_date'])->toBe('2026-11-10')->and($a['order_date'])->toBe('2026-11-06')->and($a['request_date'])->toBe('2026-11-05')->and($a['quantity_milli'])->toBe(84000)->and($a['excess_milli'])->toBe(0);
|
||||
$b = $engine->calculate([...$p, 'lead_days' => 2, 'minimum_milli' => 0, 'pack_milli' => 1000], 100000, '2026-11-01', [['date' => '2026-11-03', 'quantity' => 30000]], [['date' => '2026-11-10', 'quantity' => 40000, 'confirmed' => true]]);
|
||||
expect($b['risk_date'])->toBe('2026-11-06')->and($b['shortage_date'])->toBe('2026-11-09')->and($b['quantity_milli'])->toBe(80000)->and($b['series'][2]['balance_milli'])->toBe(50000);
|
||||
$c = $engine->calculate([...$p, 'safety_milli' => 35000, 'target_milli' => 50000, 'maximum_milli' => 65000, 'minimum_milli' => 40000, 'pack_milli' => 24000], 35000, '2026-11-01');
|
||||
expect($c['base_milli'])->toBe(15000)->and($c['quantity_milli'])->toBe(48000)->and($c['excess_milli'])->toBe(18000)->and($c['requires_exception'])->toBeTrue();
|
||||
expect($engine->calculate([...$p, 'daily_milli' => null], 120000, '2026-11-01')['available'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('supply approval is independent idempotent versioned and isolated by company', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['supply_prepare', 'supply_review']);
|
||||
supplyGrant($f['receiver'], ['supply_read', 'supply_review']);
|
||||
$input = supplyInput(supplyPolicy($f));
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
|
||||
expect(SupplyRecord::query()->count())->toBe(1);
|
||||
$draft = SupplyRecord::query()->sole();
|
||||
$approve = supplyInput([], 'approve', $draft);
|
||||
$this->postJson('/compras/fornecimento/policy', $approve)->assertUnprocessable();
|
||||
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/policy', $approve)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/policy', $approve)->assertRedirect();
|
||||
expect(SupplyRecord::query()->count())->toBe(2)->and($draft->fresh()->state)->toBe('draft');
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f), 'save', $draft))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=calendar')->assertOk();
|
||||
$foreign = receiptFixture();
|
||||
supplyGrant($foreign['issuer'], ['supply_prepare']);
|
||||
$this->actingAs($foreign['issuer'])->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f)))->assertNotFound();
|
||||
});
|
||||
|
||||
test('supply source data comes from the catalogue and cannot be forged or overwritten after approval', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['supply_prepare']);
|
||||
supplyGrant($f['receiver'], ['supply_review']);
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', supplyInput([...supplyPolicy($f), 'name' => 'FORGED']))->assertRedirect();
|
||||
$draft = SupplyRecord::query()->sole();
|
||||
expect($draft->snapshot['item']['name'])->toBe($f['stock']->name)->and($draft->data)->not->toHaveKey('name');
|
||||
$f['stock']->update(['name' => 'Nome alterado posteriormente']);
|
||||
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/policy', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
expect(fn () => DB::table('supply_records')->where('id', $draft->id)->delete())->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('import tracking cannot bypass permissions create duplicate orders or make arrived goods usable', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['import_prepare', 'procurement']);
|
||||
supplyGrant($f['receiver'], ['warehouse', 'import_customs', 'import_read']);
|
||||
$input = supplyInput(['purchase_order_id' => $f['order']->id, 'purpose' => 'internal', 'jurisdiction' => 'Jurisdição de ensaio', 'origin_country' => 'País A', 'dispatch_country' => 'País B', 'regime' => 'Regime documentado']);
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/import', $input)->assertRedirect();
|
||||
$draft = SupplyRecord::query()->sole();
|
||||
$this->postJson('/compras/fornecimento/import', supplyInput($input['data']))->assertUnprocessable();
|
||||
$event = ['evidence' => 'Documento de ensaio', 'event_date' => today()->toDateString()];
|
||||
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'release', $draft))->assertForbidden();
|
||||
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'transit', $draft))->assertRedirect();
|
||||
$transit = SupplyRecord::query()->latest('id')->first();
|
||||
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'arrive', $transit))->assertRedirect();
|
||||
$arrived = SupplyRecord::query()->latest('id')->first();
|
||||
$this->actingAs($f['receiver'])->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/import', supplyInput($event, 'release', $arrived))->assertRedirect();
|
||||
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f))->assertRedirect();
|
||||
expect(app(StockWorkflow::class)->balance($f['stock']->id, $f['warehouse']->id)['quantity'])->toBe(600);
|
||||
});
|
||||
|
||||
test('only one habitual agreement overlaps an item and warehouse and stale supplier approval is refused', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['supply_prepare']);
|
||||
supplyGrant($f['receiver'], ['supply_review']);
|
||||
$data = ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
|
||||
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addMonth()->toDateString(), 'origin' => 'local', 'currency' => 'MZN', 'price_minor' => null, 'lead_days' => null, 'reference' => 'Contrato fictício'];
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/agreement', supplyInput($data))->assertRedirect();
|
||||
$draft = SupplyRecord::query()->sole();
|
||||
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $draft))->assertRedirect();
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/agreement', supplyInput($data))->assertRedirect();
|
||||
$duplicate = SupplyRecord::query()->latest('id')->first();
|
||||
$this->actingAs($f['receiver'])->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $duplicate))->assertUnprocessable();
|
||||
expect($draft->data['price_minor'])->toBeNull()->and($draft->data['lead_days'])->toBeNull();
|
||||
$supplier = Supplier::query()->findOrFail($f['order']->supplier_id);
|
||||
$supplier->update(['version' => $supplier->version + 1]);
|
||||
$this->postJson('/compras/fornecimento/agreement', supplyInput([], 'approve', $duplicate))->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('revoked access and forged operation retries do not alter existing policies', function () {
|
||||
$f = receiptFixture();
|
||||
supplyGrant($f['issuer'], ['supply_prepare']);
|
||||
$input = supplyInput(supplyPolicy($f));
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/policy', $input)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/policy', [...$input, 'reason' => 'Outro fundamento com a mesma chave.'])->assertUnprocessable();
|
||||
DB::table('organization_memberships')->where('user_id', $f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->postJson('/compras/fornecimento/policy', supplyInput(supplyPolicy($f)))->assertForbidden();
|
||||
expect(SupplyRecord::query()->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\SupplySuggestions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/SuggestionFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = suggestionFixture();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
});
|
||||
|
||||
function saveSuggestion($test, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $test->f['policy']->record_key, ...$extra]))->assertRedirect();
|
||||
|
||||
return app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
}
|
||||
|
||||
function approveSuggestion($test, SupplyRecord $draft, array $extra = []): SupplyRecord
|
||||
{
|
||||
$test->actingAs($test->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput($extra, 'approve', $draft))->assertRedirect();
|
||||
|
||||
return app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
}
|
||||
|
||||
test('suggestion freezes the forecast catalogue agreement and company defaults without repeating descriptors', function () {
|
||||
$input = supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => null, 'name' => 'FORGED', 'snapshot' => ['balance' => 999]]);
|
||||
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
|
||||
$saved = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect($saved->data['quantity_milli'])->toBe(84000)->and($saved->data['unit_price'])->toBe('10.00')->and($saved->data['price_source'])->toBe('agreement')
|
||||
->and($saved->snapshot['item']['name'])->toBe($this->f['stock']->name)->and($saved->snapshot['balance']['quantity'])->toBe(120000)
|
||||
->and($saved->snapshot['projection']['risk_date'])->toBe(today()->addDays(9)->toDateString());
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $page) => $page->component('supply/suggestions')->has('records', 1)->where('canPrepare', true)->where('canConvert', true));
|
||||
});
|
||||
|
||||
test('independent review and a single conversion create an ordinary draft with server owned catalogue facts', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
supplyGrant($this->f['issuer'], ['supply_suggest_review']);
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$approved = approveSuggestion($this, $draft);
|
||||
supplyGrant($this->f['receiver'], ['supply_suggest_convert', 'requester']);
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
||||
$input = supplyInput(['cost_center_id' => $this->f['r']->cost_center_id, 'quantity_milli' => 999, 'unit_price' => '0.01', 'description' => 'FORGED'], 'convert', $approved);
|
||||
$before = Requisition::query()->count();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/suggestion', $input)->assertRedirect();
|
||||
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
$request = Requisition::query()->whereKey($converted->data['requisition_id'])->firstOrFail();
|
||||
$line = $request->items()->sole();
|
||||
expect(Requisition::query()->count())->toBe($before + 1)->and($request->status)->toBe('draft')->and($request->requester_id)->toBe($this->f['issuer']->id)
|
||||
->and($line->quantity)->toBe('84.000')->and($line->unit_price_minor)->toBe(1000)->and($line->description)->toBe($this->f['stock']->name)
|
||||
->and($line->catalogue_snapshot['stock_item_id'])->toBe($this->f['stock']->id)->and($converted->snapshot['conversion']['requisition_created']['number'])->toBe($request->number);
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput($input['data'], 'convert', $converted))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertUnprocessable();
|
||||
$this->get('/compras/fornecimento?view=suggestions')->assertInertia(fn (Assert $page) => $page->where('requestLinks.'.$request->id.'.id', $request->id));
|
||||
$this->get('/requisicoes/'.$request->id)->assertInertia(fn (Assert $page) => $page->where('replenishmentSource.approval_version', $approved->version)->where('replenishmentSource.quantity_milli', 84000)->missing('replenishmentSource.supplier'));
|
||||
});
|
||||
|
||||
test('changed source data blocks approval and conversion and refresh preserves the previous version', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
$oldHash = $draft->snapshot['hash'];
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/reservation', supplyInput(['stock_item_id' => $this->f['stock']->id, 'warehouse_id' => $this->f['warehouse']->id,
|
||||
'quantity_milli' => 1000, 'needed_on' => today()->addDay()->toDateString(), 'additional' => true, 'reference' => 'Nova necessidade conhecida']))->assertRedirect();
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key], 'save', $draft))->assertRedirect();
|
||||
$new = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect($new->snapshot['hash'])->not->toBe($oldHash)->and($draft->fresh()->snapshot['hash'])->toBe($oldHash);
|
||||
$approved = approveSuggestion($this, $new);
|
||||
$this->f['stock']->update(['name' => 'Cadastro alterado depois da aprovação']);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
||||
expect($approved->fresh()->state)->toBe('approved');
|
||||
});
|
||||
|
||||
test('manual plans require acknowledgement and packaging and maximum constraints are enforced', function () {
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => 85000]))->assertUnprocessable();
|
||||
$draft = saveSuggestion($this, ['quantity_milli' => 96000]);
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertUnprocessable();
|
||||
$approved = approveSuggestion($this, $draft, ['confirm_exception' => true]);
|
||||
expect($approved->data['exception_confirmed'])->toBeTrue();
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'return', $approved))->assertRedirect();
|
||||
$returned = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key, 'quantity_milli' => 120000], 'save', $returned))->assertRedirect();
|
||||
$excess = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect(app(SupplySuggestions::class)->hasExcess($excess))->toBeTrue();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput(['confirm_exception' => true], 'approve', $excess))->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('foreign agreement currency is never silently converted into a local requisition estimate', function () {
|
||||
$f = suggestionFixture('USD');
|
||||
$this->actingAs($f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $f['policy']->record_key]))->assertUnprocessable();
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $f['policy']->record_key, 'unit_price' => '650.00']))->assertRedirect();
|
||||
$record = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
expect($record->data['price_source'])->toBe('manual_estimate')->and($record->data['unit_price'])->toBe('650.00')->and($record->snapshot['agreement']['data']['currency'])->toBe('USD');
|
||||
});
|
||||
|
||||
test('conversion is atomic with its journal even when final audit fails', function () {
|
||||
$approved = approveSuggestion($this, saveSuggestion($this));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$before = Requisition::query()->count();
|
||||
$this->partialMock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->andReturnUsing(function ($actor, $action) {
|
||||
if ($action === 'supply.suggestion.convert') {
|
||||
throw new RuntimeException('Injected final audit failure.');
|
||||
}
|
||||
});
|
||||
});
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved)))->toThrow(RuntimeException::class);
|
||||
expect(Requisition::query()->count())->toBe($before)->and(app(SupplyPlanning::class)->latest('suggestion')->sole()->state)->toBe('approved');
|
||||
});
|
||||
|
||||
test('tracking cannot be closed while the created request remains open and role revocation is effective', function () {
|
||||
$approved = approveSuggestion($this, saveSuggestion($this));
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $this->f['r']->cost_center_id], 'convert', $approved))->assertRedirect();
|
||||
$converted = app(SupplyPlanning::class)->latest('suggestion')->sole();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertUnprocessable();
|
||||
Requisition::query()->whereKey($converted->data['requisition_id'])->firstOrFail()->update(['status' => 'rejected']);
|
||||
$this->postJson('/compras/fornecimento/suggestion', supplyInput([], 'close', $converted))->assertRedirect();
|
||||
expect(app(SupplyPlanning::class)->latest('suggestion')->sole()->state)->toBe('closed');
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['policy_key' => $this->f['policy']->record_key]))->assertForbidden();
|
||||
});
|
||||
|
||||
test('another company cannot resolve a suggestion or convert with another department cost center', function () {
|
||||
$draft = saveSuggestion($this);
|
||||
$other = suggestionFixture();
|
||||
$this->actingAs($other['receiver'])->postJson('/compras/fornecimento/suggestion', supplyInput([], 'approve', $draft))->assertNotFound();
|
||||
$approved = approveSuggestion($this, $draft);
|
||||
$this->actingAs($this->f['issuer'])->postJson('/compras/fornecimento/suggestion', supplyInput(['cost_center_id' => $other['r']->cost_center_id], 'convert', $approved))->assertUnprocessable();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
test('availability reuses physical balance without treating planning needs as physical reservations', function () {
|
||||
$f = receiptFixture('100.000');
|
||||
supplyGrant($f['receiver'], ['warehouse', 'warehouse_availability_read', 'supply_reserve', 'supply_review']);
|
||||
supplyGrant($f['issuer'], ['supply_prepare']);
|
||||
$this->actingAs($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '10.000'));
|
||||
app(SupplyCoordination::class)->save($f['receiver'], 'reservation', supplyInput(['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id,
|
||||
'quantity_milli' => 20000, 'needed_on' => today()->toDateString(), 'additional' => true, 'reference' => 'Necessidade planeada de ensaio']));
|
||||
$this->actingAs($f['issuer']);
|
||||
$draft = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'safety_milli' => 5000, 'minimum_stock_milli' => 10000]));
|
||||
$this->actingAs($f['receiver']);
|
||||
app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $draft));
|
||||
$movements = DB::table('stock_movements')->count();
|
||||
$this->get('/stock/disponibilidade?warehouse_id='.$f['warehouse']->id)->assertOk()->assertInertia(fn (Assert $p) => $p
|
||||
->component('stock/availability')->where('rows.data.0.physical_milli', 10000)->where('rows.data.0.usable_milli', 10000)
|
||||
->where('rows.data.0.planned_milli', 20000)->where('rows.data.0.physical_commitment_milli', 0)->where('rows.data.0.free_milli', 10000)->where('rows.data.0.at_minimum', true));
|
||||
expect(DB::table('stock_movements')->count())->toBe($movements);
|
||||
});
|
||||
|
||||
test('availability is tenant scoped and does not disclose planning without permission', function () {
|
||||
$f = receiptFixture();
|
||||
$this->actingAs($f['receiver'])->get('/stock/disponibilidade?warehouse_id='.$f['warehouse']->id)->assertOk()->assertInertia(fn (Assert $p) => $p
|
||||
->where('planningVisible', false)->where('rows.data.0.physical_milli', 0)->where('rows.data.0.planned_milli', null));
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($f['receiver'])->getJson('/stock/disponibilidade?warehouse_id='.$foreign['warehouse']->id)->assertUnprocessable();
|
||||
$requester = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($requester)->get('/stock/disponibilidade')->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/SupplyFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture('100.000');
|
||||
$this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['issuer']->department_id]);
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
supplyGrant($this->f['receiver'], ['warehouse', 'warehouse_request_deliver']);
|
||||
supplyGrant($this->reviewer, ['warehouse_request_review', 'warehouse_request_read']);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '10.000'));
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->draftData = ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
|
||||
'destination' => 'Oficina interna', 'reason' => 'Material para manutenção interna.', 'priority' => 'normal', 'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '4.000', 'use' => 'internal', 'equipment_id' => null]]];
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $this->draftData);
|
||||
});
|
||||
|
||||
function warehouseCommand($test, $actor, string $action, array $extra = []): array
|
||||
{
|
||||
$test->actingAs($actor);
|
||||
$input = ['operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'], 'action' => $action, 'reason' => 'Registo efectivo da intervenção de teste.', ...$extra];
|
||||
$test->postJson('/stock/requisicoes/'.$test->r->id.'/eventos', $input)->assertRedirect();
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
test('internal request is the exit document and partial deliveries consume only actual quantities with independent receipt', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
$delivery = warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '1.500']]]);
|
||||
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $delivery)->assertRedirect();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(8500);
|
||||
$p = app(WarehouseFulfilment::class)->progress($this->r);
|
||||
expect($p['state'])->toBe('partial')->and($p['remaining'])->toBe([2500]);
|
||||
$batch = WarehouseRequestEvent::query()->where('action', 'deliver')->sole();
|
||||
warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $batch->id]);
|
||||
warehouseCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
|
||||
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->first();
|
||||
$confirm = warehouseCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
||||
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', $confirm)->assertRedirect();
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('closed')
|
||||
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(6000)
|
||||
->and(DB::table('invoices')->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0);
|
||||
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/request')->where('progress.state', 'closed')->where('progress.delivered.0', 4000));
|
||||
});
|
||||
|
||||
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_review']);
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
|
||||
$this->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'authorize', 'reason' => 'Tentativa de auto aprovação.'])->assertForbidden();
|
||||
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'deliver', 'reason' => 'Entrega não autorizada.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
|
||||
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'authorize', 'reason' => 'Decisão numa página antiga.'])->assertUnprocessable();
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
supplyGrant($this->reviewer, ['warehouse', 'warehouse_request_deliver']);
|
||||
$this->actingAs($this->reviewer)->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Decisor tenta entregar material.', 'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertForbidden();
|
||||
$this->actingAs($this->f['receiver'])->postJson($url, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega parcial sem autorização.', 'lines' => [['index' => 0, 'quantity' => '1.000']]])->assertUnprocessable();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('shortage on a later line rolls back the entire delivery and preserves the authorization', function () {
|
||||
$other = StockItem::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'ZERO', 'name' => 'Artigo sem saldo', 'unit' => 'cx']);
|
||||
$data = $this->draftData;
|
||||
$data['operation_key'] = (string) Str::uuid();
|
||||
$data['lines'][] = ['stock_item_id' => $other->id, 'quantity' => '1.000', 'use' => 'internal'];
|
||||
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], $data);
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
$count = DB::table('stock_movements')->count();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega de conjunto com saldo insuficiente.',
|
||||
'lines' => [['index' => 0, 'quantity' => '1.000'], ['index' => 1, 'quantity' => '1.000']]])->assertUnprocessable();
|
||||
expect(DB::table('stock_movements')->count())->toBe($count)->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
|
||||
});
|
||||
|
||||
test('rejection prevents delivery without changing stock', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
warehouseCommand($this, $this->reviewer, 'reject');
|
||||
$this->actingAs($this->f['receiver'])->postJson('/stock/requisicoes/'.$this->r->id.'/eventos', ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Tentativa sobre requisição rejeitada.',
|
||||
'lines' => [['index' => 0, 'quantity' => '4.000']]])->assertUnprocessable();
|
||||
expect(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('changed source and excess quantities are refused on the live document', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
||||
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
||||
$url = '/stock/requisicoes/'.$this->r->id.'/eventos';
|
||||
$payload = ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver', 'reason' => 'Entrega efectivamente apresentada.', 'lines' => [['index' => 0, 'quantity' => '4.001']]];
|
||||
$this->actingAs($this->f['receiver'])->postJson($url, $payload)->assertUnprocessable();
|
||||
$this->f['stock']->update(['name' => 'Especificação alterada depois da autorização']);
|
||||
$payload['lines'][0]['quantity'] = '1.000';
|
||||
$payload['operation_key'] = (string) Str::uuid();
|
||||
$this->postJson($url, $payload)->assertUnprocessable();
|
||||
expect(app(WarehouseFulfilment::class)->progress($this->r)['delivered'])->toBe([0]);
|
||||
});
|
||||
|
||||
test('failure after recording the physical issue rolls back document event and stock together', function () {
|
||||
warehouseCommand($this, $this->f['issuer'], 'submit');
|
||||
warehouseCommand($this, $this->reviewer, 'authorize');
|
||||
$movements = DB::table('stock_movements')->count();
|
||||
$operations = DB::table('stock_operations')->count();
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andReturnUsing(function ($actor, $action) {
|
||||
if ($action === 'warehouse_request.deliver') {
|
||||
throw new RuntimeException('Injected final document audit failure');
|
||||
}
|
||||
});
|
||||
$this->actingAs($this->f['receiver']);
|
||||
expect(fn () => app(WarehouseFulfilment::class)->command($this->f['receiver'], $this->r->id, ['operation_key' => (string) Str::uuid(), 'version' => 2, 'action' => 'deliver',
|
||||
'reason' => 'Entrega integral de teste.', 'lines' => [['index' => 0, 'quantity' => '4.000']]]))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('stock_movements')->count())->toBe($movements)->and(DB::table('stock_operations')->count())->toBe($operations)
|
||||
->and(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('authorized');
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
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();
|
||||
supplyGrant($this->f['issuer'], ['warehouse_request_prepare']);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->data = ['operation_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'cost_center_id' => $this->f['r']->cost_center_id,
|
||||
'destination' => 'Oficina da unidade', 'reason' => 'Material para manutenção da unidade.', 'priority' => 'normal',
|
||||
'lines' => [['stock_item_id' => $this->f['stock']->id, 'quantity' => '2.500', 'use' => 'internal', 'equipment_id' => null]]];
|
||||
});
|
||||
|
||||
test('warehouse draft freezes catalogue and destination once without stock budget or purchase effects', function () {
|
||||
$moves = DB::table('stock_movements')->count();
|
||||
$purchases = DB::table('requisitions')->count();
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertRedirect();
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertRedirect();
|
||||
$record = WarehouseRequest::query()->sole();
|
||||
expect($record->snapshot['lines'][0]['quantity_milli'])->toBe(2500)->and($record->snapshot['lines'][0]['item']['unit'])->toBe($this->f['stock']->unit)
|
||||
->and($record->snapshot['destination'])->toBe($this->data['destination'])
|
||||
->and(DB::table('stock_movements')->count())->toBe($moves)->and(DB::table('requisitions')->count())->toBe($purchases)
|
||||
->and(DB::table('budget_entries')->count())->toBe(0);
|
||||
$this->f['stock']->update(['name' => 'Nome futuro do artigo']);
|
||||
expect($record->fresh()->snapshot['lines'][0]['item']['name'])->not->toBe('Nome futuro do artigo');
|
||||
$this->postJson('/stock/requisicoes', [...$this->data, 'destination' => 'Outro local'])->assertUnprocessable();
|
||||
$this->get('/stock/requisicoes')->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/requests')->where('records.total', 1)->missing('records.data.0.input_hash'));
|
||||
});
|
||||
|
||||
test('equipment destination is conditional and selected from the permitted catalogue', function () {
|
||||
$this->data['lines'][0]['use'] = 'equipment';
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertUnprocessable();
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério de teste.', 'is_critical' => false, 'created_at' => now()]);
|
||||
$equipment = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id, 'department_id' => $this->f['issuer']->department_id,
|
||||
'criticality_level_id' => $level->id, 'code' => 'EQ-1', 'description' => 'Máquina da oficina', 'area' => 'Oficina', 'status' => 'active']);
|
||||
$this->data['lines'][0]['equipment_id'] = $equipment->id;
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertRedirect();
|
||||
expect(WarehouseRequest::query()->sole()->snapshot['lines'][0]['equipment']['code'])->toBe('EQ-1');
|
||||
$this->data['operation_key'] = (string) Str::uuid();
|
||||
$this->data['lines'][0]['use'] = 'internal';
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertUnprocessable();
|
||||
$this->data['lines'][0]['use'] = 'sale';
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('warehouse draft rejects another company sources and revoked access', function () {
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/stock/requisicoes', [...$this->data, 'warehouse_id' => $foreign['warehouse']->id])->assertNotFound();
|
||||
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->postJson('/stock/requisicoes', $this->data)->assertForbidden();
|
||||
expect(DB::table('warehouse_requests')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('warehouse draft audit failure leaves no partial document', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected draft audit failure'));
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->postJson('/stock/requisicoes', $this->data))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('warehouse_requests')->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\PeopleContractHandoff;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actor = $this->f['issuer'];
|
||||
$this->actor->forceFill(['purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$this->f['tenant']->update(['rules_provisional' => false, 'work_context_policy' => ['revision' => 1, 'enabled' => array_keys(WorkContext::OPTIONS)]]);
|
||||
config(['work_context.entitlements.'.$this->actor->tenant_id => array_keys(WorkContext::OPTIONS)]);
|
||||
$this->actingAs($this->actor);
|
||||
});
|
||||
|
||||
test('selection intersects product configuration and permission without granting functions', function () {
|
||||
$before = app(OrganizationAccess::class)->permissions($this->actor);
|
||||
config(['work_context.entitlements.'.$this->actor->tenant_id => ['procurement.internal']]);
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertForbidden();
|
||||
$this->post('/contexto', ['area' => 'procurement'])->assertRedirect('/dashboard');
|
||||
expect(session('work_context.key'))->toBe('procurement.internal')
|
||||
->and(app(OrganizationAccess::class)->permissions($this->actor))->toBe($before);
|
||||
$this->get('/requisicoes')->assertOk();
|
||||
$this->get('/pessoas')->assertForbidden();
|
||||
$this->get('/pessoas/relatorios')->assertForbidden();
|
||||
$this->get('/pessoas/relatorios.csv')->assertForbidden();
|
||||
$this->post('/pessoas', [])->assertForbidden();
|
||||
});
|
||||
|
||||
test('two choices require explicit option and none exposes no business data on dashboard', function () {
|
||||
$this->post('/contexto', ['area' => 'procurement'])->assertForbidden();
|
||||
$this->get('/dashboard')->assertOk()->assertInertia(fn (Assert $page) => $page->component('work-context')->missing('stats')->missing('recent')->where('context.active', null));
|
||||
config(['work_context.entitlements.'.$this->actor->tenant_id => []]);
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('context.available', []));
|
||||
$this->get('/requisicoes')->assertForbidden();
|
||||
});
|
||||
|
||||
test('context changes invalidate old area operations and do not change submitted records', function () {
|
||||
$before = $this->f['r']->fresh()->toArray();
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
|
||||
$this->get('/requisicoes/'.$this->f['r']->id)->assertNotFound();
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertNotFound();
|
||||
$this->post('/requisicoes/'.$this->f['r']->id.'/submeter')->assertNotFound();
|
||||
expect(Requisition::withoutGlobalScope('work_context')->findOrFail($this->f['r']->id)->toArray())->toBe($before);
|
||||
});
|
||||
|
||||
test('commercial withdrawal invalidates active context and pending options cannot reuse internal workflows', function () {
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
||||
config(['work_context.entitlements.'.$this->actor->tenant_id => ['procurement.sales']]);
|
||||
$this->get('/requisicoes')->assertForbidden();
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.sales'])->assertRedirect();
|
||||
$this->get('/requisicoes')->assertRedirect('/dashboard');
|
||||
$this->post('/requisicoes', [])->assertStatus(409);
|
||||
});
|
||||
|
||||
test('people reports in a pending modality return to the context panel without exposing internal data', function () {
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.third_party'])->assertRedirect('/dashboard');
|
||||
$this->get('/pessoas/relatorios')->assertRedirect('/dashboard');
|
||||
$this->get('/pessoas/relatorios.csv')->assertRedirect('/dashboard');
|
||||
$this->get('/dashboard')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('work-context')->where('context.active', 'people.third_party')->missing('processes'));
|
||||
$this->post('/pessoas', [])->assertStatus(409);
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect('/dashboard');
|
||||
$this->get('/pessoas/relatorios')->assertOk()->assertInertia(fn (Assert $page) => $page->component('people/reports'));
|
||||
});
|
||||
|
||||
test('context is bound to account and company and rejects area mismatches', function () {
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'people.internal'])->assertForbidden();
|
||||
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
||||
$other = User::factory()->create(['tenant_id' => $this->actor->tenant_id, 'department_id' => $this->actor->department_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($other)->get('/requisicoes')->assertForbidden();
|
||||
expect(AuditLog::query()->where('action', 'work_context.selected')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('area profiles do not combine permissions from other contexts and expired membership grants nothing', function () {
|
||||
$tenant = $this->actor->tenant_id;
|
||||
DB::table('organization_access')->insert(['tenant_id' => $tenant, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
foreach ([['procurement.internal', ['requester']], ['people.internal', ['admin']]] as [$key, $permissions]) {
|
||||
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $tenant, 'name' => $key, 'permissions' => json_encode($permissions), 'work_contexts' => json_encode([$key]), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $this->actor->id, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
$this->post('/contexto', ['area' => 'procurement'])->assertRedirect();
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('context.permissions', ['requester']));
|
||||
$this->get('/requisicoes')->assertInertia(fn (Assert $page) => $page->where('organizationAccess.permissions', ['requester']));
|
||||
DB::table('organization_memberships')->where('tenant_id', $tenant)->update(['expires_at' => now()->subMinute()]);
|
||||
$this->get('/requisicoes')->assertForbidden();
|
||||
});
|
||||
|
||||
test('product configuration cannot enable unpurchased options and changes are versioned audited', function () {
|
||||
config(['work_context.entitlements.'.$this->actor->tenant_id => ['procurement.internal']]);
|
||||
$body = ['version' => 1, 'enabled' => ['people.internal'], 'confirm' => true, 'reason' => 'Definir as opções adquiridas pela empresa.'];
|
||||
$this->postJson('/administracao/parametrizacao/work_contexts', $body)->assertUnprocessable();
|
||||
$body['enabled'] = ['procurement.internal'];
|
||||
$this->postJson('/administracao/parametrizacao/work_contexts', $body)->assertRedirect();
|
||||
$this->postJson('/administracao/parametrizacao/work_contexts', $body)->assertUnprocessable();
|
||||
expect($this->f['tenant']->fresh()->work_context_policy)->toBe(['revision' => 2, 'enabled' => ['procurement.internal']])
|
||||
->and(AuditLog::query()->where('action', 'organization.work_contexts')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('SGF composition excludes third party hiring even if installation requests it', function () {
|
||||
config(['work_context.composition' => 'sgf']);
|
||||
expect(app(WorkContext::class)->purchased($this->actor))->not->toContain('people.third_party');
|
||||
$this->post('/contexto', ['area' => 'people', 'option' => 'people.third_party'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('origin classification is internal and SQL cannot transfer a process to another context', function () {
|
||||
expect($this->f['r']->fresh()->work_context_key)->toBe('procurement.internal');
|
||||
expect(fn () => DB::table('requisitions')->where('id', $this->f['r']->id)->update(['work_context_key' => 'procurement.sales']))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('RH boundary refuses selection and draft contracts and performs no external request', function () {
|
||||
Http::fake();
|
||||
foreach (['selected', 'selection_review', 'contract_draft'] as $status) {
|
||||
$record = new PeopleProcess(['tenant_id' => $this->actor->tenant_id, 'status' => $status, 'work_context_key' => 'people.internal', 'contract' => ['status' => 'draft']]);
|
||||
expect(fn () => app(PeopleContractHandoff::class)->envelope($record))->toThrow(HttpException::class);
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('delivery check refuses the development fixture without deleting its records', function () {
|
||||
$before = DB::table('requisitions')->count();
|
||||
$this->artisan('sgc:delivery-check', ['tenant' => $this->actor->tenant_id])->assertFailed();
|
||||
expect(DB::table('requisitions')->count())->toBe($before);
|
||||
});
|
||||
|
||||
test('temporary maintenance may select its authorised context but cannot execute business commands', function () {
|
||||
$tenant = $this->actor->tenant_id;
|
||||
DB::table('organization_access')->insert(['tenant_id' => $tenant, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $tenant, 'name' => 'Consulta temporária', 'permissions' => json_encode(['procurement']), 'work_contexts' => json_encode(['procurement.internal']), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $this->actor->id, 'profile_id' => $profile, 'maintenance' => true, 'expires_at' => now()->addHour(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->post('/contexto', ['area' => 'procurement'])->assertRedirect('/dashboard');
|
||||
$this->get('/requisicoes')->assertOk();
|
||||
$this->post('/requisicoes', [])->assertForbidden();
|
||||
$this->post('/administracao/parametrizacao/work_contexts', [])->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\SupplyPlanning;
|
||||
|
||||
require_once __DIR__.'/ReceiptFixture.php';
|
||||
require_once __DIR__.'/SupplyFixture.php';
|
||||
|
||||
function suggestionFixture(string $currency = 'MZN'): array
|
||||
{
|
||||
$f = receiptFixture('120.000');
|
||||
supplyGrant($f['issuer'], ['procurement', 'requester', 'supply_prepare', 'supply_suggest_prepare', 'supply_suggest_convert']);
|
||||
supplyGrant($f['receiver'], ['warehouse', 'supply_review', 'supply_suggest_review', 'supply_reserve']);
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($f['receiver'], $f['order'], receiptPayload($f, '120.000'));
|
||||
app(RequisitionCatalogue::class)->save($f['receiver'], $f['stock']->id, ['version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($f['stock']), 'category' => 'Material', 'purchase_type' => 'consumable', 'published' => true, 'reason' => 'Publicação fictícia para converter sem redigitar.']);
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$agreement = app(SupplyPlanning::class)->save($f['issuer'], 'agreement', supplyInput(['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'supplier_id' => $f['order']->supplier_id,
|
||||
'habitual' => true, 'valid_from' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'origin' => $currency === 'MZN' ? 'local' : 'import', 'currency' => $currency, 'price_minor' => 1000, 'lead_days' => 4, 'reference' => 'Acordo fictício do ensaio.']));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$f['agreement'] = app(SupplyPlanning::class)->save($f['receiver'], 'agreement', supplyInput([], 'approve', $agreement));
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$policy = app(SupplyPlanning::class)->save($f['issuer'], 'policy', supplyInput([...supplyPolicy($f), 'agreement_key' => $agreement->record_key]));
|
||||
auth()->guard()->setUser($f['receiver']);
|
||||
$f['policy'] = app(SupplyPlanning::class)->save($f['receiver'], 'policy', supplyInput([], 'approve', $policy));
|
||||
|
||||
return $f;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SupplyRecord;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function supplyGrant($user, array $permissions): void
|
||||
{
|
||||
DB::table('organization_access')->insertOrIgnore(['tenant_id' => $user->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $user->tenant_id, 'name' => 'Teste '.Str::uuid(), 'permissions' => json_encode($permissions), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $user->tenant_id, 'user_id' => $user->id, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
function supplyPolicy(array $f): array
|
||||
{
|
||||
return ['stock_item_id' => $f['stock']->id, 'warehouse_id' => $f['warehouse']->id, 'purpose' => 'resale', 'origin' => 'import',
|
||||
'safety_milli' => 20000, 'minimum_stock_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000,
|
||||
'daily_milli' => 10000, 'lead_days' => 4, 'approval_days' => 1, 'horizon_days' => 30, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'basis' => 'Consumo e prazo contratual verificados no ensaio.'];
|
||||
}
|
||||
|
||||
function supplyInput(array $data, string $action = 'save', ?SupplyRecord $record = null): array
|
||||
{
|
||||
return ['operation_key' => (string) Str::uuid(), 'record_key' => $record?->record_key, 'version' => $record?->version ?? 0, 'action' => $action, 'reason' => 'Conferência fictícia com evidência no ensaio.', 'data' => $data];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Internal\InternalRequisitions;
|
||||
use Tempo\Purchasing\SgfCandidate\Port;
|
||||
|
||||
require getenv('IR_SGF_HOST').'/vendor/autoload.php';
|
||||
$app = require getenv('IR_SGF_HOST').'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require getenv('IR_CANDIDATE_ROOT').'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
$row = DB::table('ir_requests')->orderBy('id')->first();
|
||||
$actor = User::findOrFail($row->requester_id);
|
||||
auth()->guard()->setUser($actor);
|
||||
app(CompanyContext::class)->selectFor($actor->id, $row->tenant_id, $row->company_id, $row->unit_id);
|
||||
request()->headers->set('X-Purchasing-Context', 'procurement.internal');
|
||||
$record = (new InternalRequisitions(new Port($actor)))->view($row->id);
|
||||
if ($record['status'] !== 'approved' || $record['origin_key'] !== $row->origin_key) {
|
||||
throw new RuntimeException('Restored process cannot be consulted.');
|
||||
}
|
||||
echo json_encode(['restored_candidate_consultation' => true, 'id' => $record['id'], 'origin_preserved' => true, 'version' => $record['version']], JSON_THROW_ON_ERROR)."\n";
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Test harness only. Uses the SGF HTTP stack, sessions, CSRF and context middleware.
|
||||
$host = getenv('IR_SGF_HOST');
|
||||
$candidate = getenv('IR_CANDIDATE_ROOT');
|
||||
require $host.'/vendor/autoload.php';
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
config(['logging.default' => 'single', 'logging.channels.single.path' => getenv('IR_WORK_DIR').'/candidate-errors.log']);
|
||||
BudgetAdapter::guard();
|
||||
require $candidate.'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
require $candidate.'/integrations/sgf/internal-requisitions/routes.php';
|
||||
$fault = getenv('IR_FAULT_FILE');
|
||||
if (is_file($fault) && trim(file_get_contents($fault)) === 'before_commit') {
|
||||
DB::listen(function ($query) use ($fault) {
|
||||
if (str_contains($query->sql, 'insert into "ir_events"') && in_array('submitted', $query->bindings, true)) {
|
||||
unlink($fault);
|
||||
throw new RuntimeException('Injected failure before commit');
|
||||
}
|
||||
});
|
||||
}
|
||||
$request = Request::capture();
|
||||
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
|
||||
$response = $kernel->handle($request);
|
||||
if (is_file($fault) && trim(file_get_contents($fault)) === 'lost_response' && $request->isMethod('POST') && $request->is('api/internal-requisitions/*')) {
|
||||
unlink($fault);
|
||||
$response = response()->json(['message' => 'Injected lost response after commit'], 500);
|
||||
}
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Purchasing\Internal\InternalRequisitions;
|
||||
use Tempo\Purchasing\SgfCandidate\Port;
|
||||
|
||||
require getenv('IR_SGF_HOST').'/vendor/autoload.php';
|
||||
$app = require getenv('IR_SGF_HOST').'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require getenv('IR_CANDIDATE_ROOT').'/integrations/sgf/internal-requisitions/bootstrap.php';
|
||||
|
||||
$input = json_decode(file_get_contents($argv[1]), true, 64, JSON_THROW_ON_ERROR);
|
||||
$actor = User::findOrFail($input['actor']);
|
||||
auth()->guard()->setUser($actor);
|
||||
app(CompanyContext::class)->selectFor($actor->id, $input['scope']['tenant_id'], $input['scope']['company_id'], $input['scope']['unit_id']);
|
||||
request()->headers->set('X-Purchasing-Context', 'procurement.internal');
|
||||
try {
|
||||
$result = DB::transaction(function () use ($input, $actor) {
|
||||
if (($input['pause'] ?? false)) {
|
||||
DB::table('companies')->where('id', $input['scope']['company_id'])->lockForUpdate()->firstOrFail();
|
||||
echo "LOCK_HELD\n";
|
||||
flush();
|
||||
usleep(1200000);
|
||||
}
|
||||
|
||||
return (new InternalRequisitions(new Port($actor, $input['key'])))->command($input['action'], $input['id'], $input['version'], $input['key'], $input['data']);
|
||||
});
|
||||
echo json_encode(['result' => 'ok', 'id' => $result['id'], 'version' => $result['version'], 'replayed' => $result['replayed'] ?? false], JSON_THROW_ON_ERROR)."\n";
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode(['result' => 'rejected', 'class' => $e::class], JSON_THROW_ON_ERROR)."\n";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\IdentityWorkflow;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tempo\Purchasing\Delivery\InitialAuthority;
|
||||
use Tempo\Purchasing\SgfCandidate\Schema;
|
||||
|
||||
$host = getenv('IR_SGF_HOST');
|
||||
$root = getenv('IR_CANDIDATE_ROOT');
|
||||
$overlay = getenv('IR_ACCESS_OVERLAY');
|
||||
require $host.'/vendor/autoload.php';
|
||||
// Class replacements exist only in this isolated process, not in the installed host.
|
||||
require $overlay.'/app/Modules/Identity/Services/Permissions.php';
|
||||
require $overlay.'/app/Modules/Identity/Services/IdentityWorkflow.php';
|
||||
require $overlay.'/AccessOverlay.php';
|
||||
require $overlay.'/InitialAuthority.php';
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
BudgetAdapter::guard();
|
||||
require $host.'/packages/budget-control/tests/sgf-schema.php';
|
||||
require $host.'/packages/budget-control/tests/host-support.php';
|
||||
$f = budgetQaFixture('sgf');
|
||||
require $root.'/integrations/sgf/internal-requisitions/Schema.php';
|
||||
Schema::install(DB::connection());
|
||||
$scope = $f['scope'];
|
||||
$actor = $f['actor'];
|
||||
$target = $f['validator'];
|
||||
$checks = [];
|
||||
$check = function (bool $ok, string $name) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($name);
|
||||
}
|
||||
$checks[] = $name;
|
||||
};
|
||||
$deny = function (callable $work, int $status) use ($check) {
|
||||
try {
|
||||
$work();
|
||||
throw new RuntimeException('Unexpected permission success');
|
||||
} catch (HttpException $e) {
|
||||
$check($e->getStatusCode() === $status, 'denied '.$status);
|
||||
}
|
||||
};
|
||||
$grant = function ($user, $right, $unit = null) use ($scope) {
|
||||
DB::table('permission_grants')->insertOrIgnore(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'unit_id' => $unit, 'user_id' => $user, 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
|
||||
};
|
||||
$ctx = app(CompanyContext::class);
|
||||
$ctx->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
$request = function (array $data) use ($actor) {
|
||||
$r = Request::create('/api/access', 'POST', $data);
|
||||
$r->setUserResolver(fn () => User::findOrFail($actor));
|
||||
$session = new Store('access-proof', new ArraySessionHandler(120));
|
||||
$session->start();
|
||||
$session->put('company_context.token', 'isolated-context-token');
|
||||
$r->setLaravelSession($session);
|
||||
$r->headers->set('X-SGF-Context', 'isolated-context-token');
|
||||
|
||||
return $r;
|
||||
};
|
||||
$p = app(Permissions::class);
|
||||
$workflow = app(IdentityWorkflow::class);
|
||||
$grant($actor, 'access.manage');
|
||||
$grant($actor, 'unit.create');
|
||||
$grant($target, 'access.manage');
|
||||
$check(in_array('purchasing_requisitions.configure', $workflow->accessList($request([]))['catalogue']['unit'], true), 'existing administration lists new catalogue');
|
||||
$unit = $workflow->createUnit($request(['name' => 'Synthetic new unit']));
|
||||
$check(DB::table('permission_grants')->where('unit_id', $unit)->count() === 2, 'unit creation grants only legacy rights');
|
||||
$initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega inicial sintética explicitamente escolhida.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.configure']]]];
|
||||
$service = new InitialAuthority;
|
||||
$deny(fn () => $service->initialise($request($initial)), 403);
|
||||
config(['purchasing-access.initialisation_scopes' => [implode(':', $scope)]]);
|
||||
$stale = $request($initial);
|
||||
$stale->headers->set('X-SGF-Context', 'old-token');
|
||||
$deny(fn () => $service->initialise($stale), 409);
|
||||
$options = $service->options($request([]));
|
||||
$check(count($options['administrators']) === 2 && ! $options['initialised'], 'nominative authorised initialisation choices');
|
||||
$render = function (array $options, Request $request) use ($overlay) {
|
||||
ob_start();
|
||||
require $overlay.'/screen.php';
|
||||
|
||||
return ob_get_clean();
|
||||
};
|
||||
$html = $render($options, $request([]));
|
||||
$check(str_contains($html, 'name="rights"') && str_contains($html, 'X-SGF-Context') && str_contains($html, 'X-CSRF-TOKEN'), 'initial authority screen selects explicit rights and includes context and CSRF');
|
||||
$check(! str_contains($html, ' checked') && str_contains($html, 'crypto.randomUUID()'), 'screen has no selected grants and retains operation key on retry');
|
||||
$deny(fn () => $service->initialise($request([...$initial, 'administrators' => [['user_id' => $actor, 'rights' => ['company.edit']]]])), 422);
|
||||
$failAudit = true;
|
||||
DB::listen(function ($query) use (&$failAudit) {
|
||||
if ($failAudit && str_contains($query->sql, 'insert into "identity_audit"') && in_array('purchasing.access_initialised', $query->bindings, true)) {
|
||||
$failAudit = false;
|
||||
throw new RuntimeException('Injected authority audit failure');
|
||||
}
|
||||
});
|
||||
try {
|
||||
$service->initialise($request($initial));
|
||||
throw new RuntimeException('Expected audit failure');
|
||||
} catch (RuntimeException $error) {
|
||||
if ($error->getMessage() !== 'Injected authority audit failure') {
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
$check(! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']), 'audit failure rolls back initial grants');
|
||||
$before = DB::table('identity_audit')->count();
|
||||
$check($service->initialise($request($initial))['initialised'], 'explicit nominated administrator initialisation');
|
||||
$check(DB::table('identity_audit')->count() === $before + 1, 'initial authority audited');
|
||||
$check(! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.approve.finance', $scope['unit_id']), 'unselected rights absent');
|
||||
$check($service->initialise($request($initial))['replayed'], 'initialisation retry idempotent');
|
||||
$options = $service->options($request([]));
|
||||
$check($options['initialised'] && ! str_contains($render($options, $request([])), '<form'), 'initialised screen cannot offer a second initial grant');
|
||||
$change = ['email' => User::findOrFail($target)->email, 'permission' => 'purchasing_requisitions.configure', 'unit_id' => $scope['unit_id'], 'reason' => 'Delegação sintética explícita.'];
|
||||
$workflow->changeAccess($request($change), true);
|
||||
$check($p->has($target, $scope['tenant_id'], $scope['company_id'], $change['permission'], $scope['unit_id']), 'normal administrative grant');
|
||||
$workflow->changeAccess($request($change), false);
|
||||
$check(! $p->has($target, $scope['tenant_id'], $scope['company_id'], $change['permission'], $scope['unit_id']), 'normal administrative revocation');
|
||||
$deny(fn () => $workflow->changeAccess($request([...$change, 'permission' => 'purchasing_requisitions.approve.finance']), true), 403);
|
||||
$grant($target, 'purchasing_requisitions.approve.finance', $scope['unit_id']);
|
||||
$deny(fn () => $workflow->changeAccess($request([...$change, 'permission' => 'unit.view']), false), 403);
|
||||
$check($p->has($target, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']), 'failed cascade preserves unit access');
|
||||
$grant($actor, 'purchasing_requisitions.approve.finance', $scope['unit_id']);
|
||||
// Fixture also assigned budget rights; actor needs each affected right to revoke the full unit.
|
||||
foreach (DB::table('permission_grants')->where($scope)->where('user_id', $target)->pluck('permission') as $right) {
|
||||
$grant($actor, $right, $scope['unit_id']);
|
||||
}
|
||||
$workflow->changeAccess($request([...$change, 'permission' => 'unit.view']), false);
|
||||
$check(DB::table('permission_grants')->where($scope)->where('user_id', $target)->count() === 0, 'unit revocation removes derived catalogue rights');
|
||||
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'purchasing_requisitions.configure')->delete();
|
||||
$check($service->initialise($request($initial))['replayed'] && ! $p->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_requisitions.configure', $scope['unit_id']), 'initialisation replay never resurrects revoked rights');
|
||||
$deny(fn () => $service->initialise($request([...$initial, 'operation_key' => (string) Str::uuid()])), 409);
|
||||
DB::table('permission_grants')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('user_id', $actor)->where('permission', 'access.manage')->delete();
|
||||
$deny(fn () => $service->initialise($request($initial)), 403);
|
||||
$deny(fn () => $service->options($request([])), 403);
|
||||
echo json_encode(['passed' => true, 'checks' => $checks, 'installed' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/** Code/dependencies only: no .env, cache, accounts, documents or runtime data copied. */
|
||||
function compositionClone(string $host, string $bundle, string $suffix): string
|
||||
{
|
||||
$clone = sys_get_temp_dir().'/sgf-composition-'.$suffix;
|
||||
if (file_exists($clone)) {
|
||||
throw new RuntimeException('Clone must be new.');
|
||||
}
|
||||
mkdir($clone, 0700);
|
||||
copy($host.'/composer.json', $clone.'/composer.json');
|
||||
$copy = function (string $source, string $target) use (&$copy): void {
|
||||
if (! is_dir($target)) {
|
||||
mkdir($target, 0700, true);
|
||||
}
|
||||
foreach (new DirectoryIterator($source) as $file) {
|
||||
if ($file->isDot() || $file->isLink() || str_starts_with($file->getFilename(), '.')) {
|
||||
continue;
|
||||
}
|
||||
$to = $target.'/'.$file->getFilename();
|
||||
if ($file->isDir()) {
|
||||
$copy($file->getPathname(), $to);
|
||||
} else {
|
||||
copy($file->getPathname(), $to);
|
||||
}
|
||||
}
|
||||
};
|
||||
foreach (['app', 'config', 'routes', 'database/migrations', 'packages', 'vendor'] as $directory) {
|
||||
$copy($host.'/'.$directory, $clone.'/'.$directory);
|
||||
}
|
||||
mkdir($clone.'/bootstrap/cache', 0700, true);
|
||||
foreach (['app.php', 'providers.php'] as $file) {
|
||||
copy($host.'/bootstrap/'.$file, $clone.'/bootstrap/'.$file);
|
||||
}
|
||||
foreach (['public', 'storage/logs', 'storage/framework/sessions', 'storage/framework/views', 'storage/framework/cache'] as $directory) {
|
||||
mkdir($clone.'/'.$directory, 0700, true);
|
||||
}
|
||||
$copy($bundle, $clone.'/packages/internal-requisitions');
|
||||
$manifest = json_decode(file_get_contents($bundle.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
||||
if (hash_file('sha256', $clone.'/'.$file) !== $hashes['before_sha256']) {
|
||||
throw new RuntimeException('Clone baseline mismatch.');
|
||||
}
|
||||
copy($bundle.'/'.$file, $clone.'/'.$file);
|
||||
}
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>getenv("IR_HTTP_ENABLED")==="1","target"=>getenv("IR_HTTP_TARGET"),"scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
file_put_contents($clone.'/config/purchasing-access.php', '<?php return ["initialisation_scopes"=>[getenv("IR_HTTP_SCOPE")]];');
|
||||
|
||||
return $clone;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
|
||||
$original = getenv('IR_SGF_HOST');
|
||||
$clone = getenv('IR_CLONE');
|
||||
$root = getenv('IR_CANDIDATE_ROOT');
|
||||
require $clone.'/vendor/autoload.php';
|
||||
$app = require $clone.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
foreach (['0001_01_01_000000_create_users_table.php', '2026_10_25_000000_create_identity_foundation.php', '2026_10_25_010000_add_identity_workflow.php'] as $file) {
|
||||
(require $clone.'/database/migrations/'.$file)->up();
|
||||
}
|
||||
Installation::migrate(DB::connection(), Installation::target(DB::connection()), str_repeat('a', 64), 'Synthetic HTTP composition preparation');
|
||||
require $clone.'/packages/budget-control/tests/host-support.php';
|
||||
$f = budgetQaFixture('sgf');
|
||||
$scope = $f['scope'];
|
||||
$target = Installation::target(DB::connection());
|
||||
$scopeKey = implode(':', $scope);
|
||||
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [$scopeKey]]);
|
||||
budgetQaCreate('sgf', $f, 1000000, true);
|
||||
$actor = $f['actor'];
|
||||
$head = $f['validator'];
|
||||
DB::table('permission_grants')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'user_id' => $actor, 'unit_id' => null, 'permission' => 'access.manage', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('permission_grants')->where($scope)->where('user_id', $actor)->where('permission', 'like', 'purchasing_%')->delete();
|
||||
$password = bin2hex(random_bytes(24));
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$u = User::findOrFail($id);
|
||||
$u->password = $password;
|
||||
$u->save();
|
||||
}
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$port = (int) substr(strrchr($address, ':'), 1);
|
||||
$server = new Process([PHP_BINARY, '-S', '127.0.0.1:'.$port, '-t', $clone.'/public', $root.'/tests/Support/sgf-composition-router.php'], $clone, ['IR_HTTP_ENABLED' => '1', 'IR_HTTP_TARGET' => $target, 'IR_HTTP_SCOPE' => $scopeKey], null, 120);
|
||||
$server->start();
|
||||
$checks = [];
|
||||
$check = function ($ok, $label) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($label);
|
||||
}$checks[] = $label;
|
||||
};
|
||||
$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(AppServiceProvider::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'provider really loaded from isolated copy');
|
||||
$check(str_starts_with(str_replace('\\', '/', (new ReflectionClass(BudgetAdapter::class))->getFileName()), str_replace('\\', '/', $clone).'/app/'), 'budget adapter really loaded from isolated copy');
|
||||
$plan = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $original.'/scripts/sgf.ps1', 'php', $clone.'/packages/internal-requisitions/scripts/sgf-internal-install.php', 'plan', $clone], $clone, null, null, 60);
|
||||
$plan->mustRun();
|
||||
$check(json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR)['schema_prepared'], 'new bundle bootstrap accepted by installer integrity check');
|
||||
try {
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$clients = [];
|
||||
$anonymous = new Client(['http_errors' => false]);
|
||||
$check($anonymous->get('http://127.0.0.1:'.$port.'/purchasing/internal-requisitions/access-initialisation', ['headers' => ['Accept' => 'application/json']])->getStatusCode() === 401, 'anonymous direct initialisation URL denied');
|
||||
$call = function ($id, $path, $method = 'GET', $data = null, $headers = []) use (&$clients, $port) {
|
||||
$c = &$clients[$id];
|
||||
$opts = ['http_errors' => false, 'headers' => array_merge(['Accept' => 'application/json', 'X-CSRF-TOKEN' => $c['csrf'], 'X-SGF-Context' => $c['token'] ?? '', 'X-Purchasing-Context' => 'procurement.internal'], $headers)];
|
||||
if ($data !== null) {
|
||||
$opts['json'] = $data;
|
||||
}
|
||||
$r = $c['http']->request($method, 'http://127.0.0.1:'.$port.$path, $opts);
|
||||
$body = (string) $r->getBody();
|
||||
|
||||
return ['status' => $r->getStatusCode(), 'data' => json_decode($body, true), 'body' => $body];
|
||||
};
|
||||
foreach ([$actor, $head] as $id) {
|
||||
$http = new Client(['cookies' => new CookieJar, 'timeout' => 15]);
|
||||
$s = json_decode((string) $http->get('http://127.0.0.1:'.$port.'/api/session', ['headers' => ['Accept' => 'application/json']])->getBody(), true);
|
||||
$clients[$id] = ['http' => $http, 'csrf' => $s['csrf']];
|
||||
$login = $call($id, '/api/login', 'POST', ['email' => User::findOrFail($id)->email, 'password' => $password]);
|
||||
$check($login['status'] === 200, 'native clone authentication '.$id);
|
||||
$clients[$id]['csrf'] = $login['data']['csrf'];
|
||||
$selected = $call($id, '/api/context', 'POST', $scope);
|
||||
$check($selected['status'] === 200, 'native clone context '.$id);
|
||||
$clients[$id]['token'] = $selected['data']['context']['token'];
|
||||
}
|
||||
$page = $call($actor, '/purchasing/internal-requisitions/access-initialisation');
|
||||
$check($page['status'] === 200 && str_contains($page['body'], 'Administrador nominativo'), 'provider registers initial authority page without prior functional rights');
|
||||
$initial = ['operation_key' => (string) Str::uuid(), 'reason' => 'Entrega sintética através da composição HTTP.', 'administrators' => [['user_id' => $actor, 'rights' => ['purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure', 'purchasing_requisitions.approve.department_head', 'purchasing_budget.record']]]];
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial, ['X-CSRF-TOKEN' => 'bad'])['status'] === 419, 'initial authority CSRF enforced');
|
||||
$check($call($actor, '/api/internal-requisitions/access-initialisation', 'POST', $initial)['status'] === 200, 'initial authority through normal middleware');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 409, 'work page requires explicit module selection');
|
||||
$oldToken = $clients[$actor]['token'];
|
||||
$selected = $call($actor, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$check($selected['status'] === 200, 'new requisition view right makes purchasing selectable');
|
||||
$clients[$actor]['token'] = $selected['data']['context']['token'];
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-SGF-Context' => $oldToken])['status'] === 409, 'stale module generation refused');
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 200, 'provider registers operational work page');
|
||||
$check($call($actor, '/api/internal-requisitions', 'GET', null, ['X-Purchasing-Context' => 'procurement.third_party'])['status'] === 403, 'different enterprise option refused by operational composition');
|
||||
foreach (['purchasing_requisitions.view', 'purchasing_requisitions.approve.department_head'] as $right) {
|
||||
$r = $call($actor, '/api/accesses', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => $right, 'reason' => 'Atribuição sintética explícita.']);
|
||||
$check($r['status'] === 200, 'native administrative delegation '.$right);
|
||||
}
|
||||
$selected = $call($head, '/api/context', 'POST', $scope + ['module_id' => 'purchasing']);
|
||||
$clients[$head]['token'] = $selected['data']['context']['token'];
|
||||
$policy = ['version' => 0, 'reason' => 'Política sintética na composição integrada.', 'enabled' => true, 'budget_required' => true, 'first_limit_minor' => 1000000, 'second_limit_minor' => 2000000, 'approval_includes_tax' => true, 'centres' => [['id' => $f['centre'], 'department_ref' => 'OPER']], 'members' => [['user_id' => $actor, 'department_ref' => 'OPER'], ['user_id' => $head, 'department_ref' => 'OPER']]];
|
||||
$check($call($actor, '/api/internal-requisitions/policy', 'POST', $policy)['status'] === 200, 'operational policy configured via HTTP');
|
||||
$draft = $call($actor, '/api/internal-requisitions/save', 'POST', ['operation_key' => (string) Str::uuid(), 'cost_center_id' => $f['centre'], 'priority' => 'normal', 'needed_by' => date('Y-m-d', time() + 864000), 'justification' => 'Requisição sintética da composição.', 'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '2.000', 'unit' => 'UN', 'unit_price' => '50.00', 'tax_rate' => '16.00']]]);
|
||||
$check($draft['status'] === 200, 'integrated operational draft');
|
||||
$id = $draft['data']['id'];
|
||||
$submitted = $call($actor, "/api/internal-requisitions/$id/submit", 'POST', ['operation_key' => (string) Str::uuid(), 'version' => 1]);
|
||||
$check($submitted['status'] === 200 && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'integrated submit uses existing budget adapter');
|
||||
$decision = ['operation_key' => (string) Str::uuid(), 'version' => $submitted['data']['version'], 'decision' => 'approved'];
|
||||
$check($call($actor, "/api/internal-requisitions/$id/decide", 'POST', $decision)['status'] === 403, 'integrated independent identity enforced');
|
||||
$approved = $call($head, "/api/internal-requisitions/$id/decide", 'POST', $decision);
|
||||
$check($approved['status'] === 200 && $approved['data']['status'] === 'approved', 'integrated approval by another identity');
|
||||
$view = $call($actor, "/api/internal-requisitions/$id");
|
||||
$check($view['status'] === 200 && count($view['data']['history']) === 3, 'integrated authorised consultation and history');
|
||||
$revoke = $call($actor, '/api/accesses/revoke', 'POST', ['email' => User::findOrFail($head)->email, 'unit_id' => $scope['unit_id'], 'permission' => 'purchasing_requisitions.view', 'reason' => 'Revogação sintética explícita.']);
|
||||
$check($revoke['status'] === 200 && $call($head, "/api/internal-requisitions/$id")['status'] === 403, 'native revocation applies to direct module URL');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>[]];');
|
||||
// Deployment configuration changes require restarting persistent PHP services.
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}
|
||||
usleep(50000);
|
||||
}
|
||||
$check($call($actor, "/api/internal-requisitions/$id")['status'] === 403, 'uncontracted module refused at server');
|
||||
file_put_contents($clone.'/config/modules.php', '<?php return ["contracted"=>["purchasing"]];');
|
||||
file_put_contents($clone.'/config/purchasing-operational.php', '<?php return ["enabled"=>false];');
|
||||
$server->stop(1);
|
||||
$server->start();
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
if (@fsockopen('127.0.0.1', $port)) {
|
||||
break;
|
||||
}usleep(50000);
|
||||
}
|
||||
$check($call($actor, '/purchasing/internal-requisitions')['status'] === 404, 'disabled composition exposes no executable work page');
|
||||
echo json_encode(['passed' => true, 'checks' => $checks, 'clone' => $clone, 'destination_activated' => false, 'normal_provider_routes' => true, 'browser_used' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
} finally {
|
||||
$server->stop(1);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
// Normal dependency/bootstrap stack of the disposable copy; provider registers module routes.
|
||||
$clone = getenv('IR_CLONE');
|
||||
require $clone.'/vendor/autoload.php';
|
||||
$app = require $clone.'/bootstrap/app.php';
|
||||
$app->handleRequest(Request::capture());
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Purchasing\BudgetAdapter;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
|
||||
$host = getenv('IR_SGF_HOST');
|
||||
$root = getenv('IR_ACCESS_OVERLAY');
|
||||
require $host.'/vendor/autoload.php';
|
||||
foreach (['Identity/Services/Permissions', 'Identity/Services/IdentityWorkflow', 'Purchasing/BudgetAdapter'] as $file) {
|
||||
require $root.'/app/Modules/'.$file.'.php';
|
||||
}
|
||||
$app = require $host.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require $root.'/integrations/sgf/operational/bootstrap.php';
|
||||
if (getenv('IR_RESTORE_VERIFY') === '1') {
|
||||
$db = DB::connection();
|
||||
$budget = DB::table('cb_budgets')->first();
|
||||
$scope = ['tenant_id' => $budget->tenant_id, 'company_id' => $budget->company_id, 'unit_id' => $budget->unit_id];
|
||||
$actor = $budget->created_by;
|
||||
auth()->loginUsingId($actor);
|
||||
app(CompanyContext::class)->selectFor($actor, ...array_values($scope));
|
||||
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => Installation::target($db), 'purchasing-operational.scopes' => [implode(':', $scope)]]);
|
||||
BudgetAdapter::guard();
|
||||
$receipt = $db->table('sgc_installations')->first();
|
||||
if ($db->table('cb_budget_entries')->where('kind', 'reserve')->count() !== 1 || ! hash_equals($receipt->schema_signature, Installation::signature($db, Installation::tables()))) {
|
||||
throw new RuntimeException('Operational recovery differs.');
|
||||
}
|
||||
if (app(Permissions::class)->has($actor, $scope['tenant_id'], $scope['company_id'], 'purchasing_budget.record', $scope['unit_id'])) {
|
||||
throw new RuntimeException('Restored revocation was lost.');
|
||||
}
|
||||
echo json_encode(['verified' => true, 'production_environment' => app()->environment('production'), 'database_outside_QA_prefix' => ! str_starts_with($db->getDatabaseName(), 'budget_core_sgf_'), 'schema_receipt_and_budget_reservation_preserved' => true, 'revocation_preserved' => true], JSON_THROW_ON_ERROR);
|
||||
exit(0);
|
||||
}
|
||||
BudgetAdapter::guard();
|
||||
require $host.'/packages/budget-control/tests/sgf-schema.php';
|
||||
if (getenv('IR_CALIBRATE') === '1') {
|
||||
echo json_encode(['passed' => true, 'schema_signature' => Installation::signature(DB::connection(), Installation::TABLES)], JSON_THROW_ON_ERROR);
|
||||
exit(0);
|
||||
}
|
||||
require $host.'/packages/budget-control/tests/host-support.php';
|
||||
$f = budgetQaFixture('sgf');
|
||||
budgetQaCreate('sgf', $f, 1000000, true);
|
||||
$db = DB::connection();
|
||||
$scope = $f['scope'];
|
||||
$ctx = app(CompanyContext::class);
|
||||
auth()->loginUsingId($f['actor']);
|
||||
$ctx->selectFor($f['actor'], $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
$target = Installation::target($db);
|
||||
$beforeGrants = DB::table('permission_grants')->count();
|
||||
$beforeBudget = DB::table('cb_budgets')->first();
|
||||
$checks = [];
|
||||
$check = function ($ok, $name) use (&$checks) {
|
||||
if (! $ok) {
|
||||
throw new RuntimeException($name);
|
||||
}
|
||||
$checks[] = $name;
|
||||
};
|
||||
$deny = function (callable $call, string $message) use ($check) {
|
||||
try {
|
||||
$call();
|
||||
throw new LogicException('Unexpected success');
|
||||
} catch (RuntimeException $error) {
|
||||
$check($error->getMessage() === $message, $message);
|
||||
}
|
||||
};
|
||||
$cli = function (array $args) use ($host, $root) {
|
||||
$process = new Process(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $host.'/scripts/sgf.ps1', 'php', $root.'/scripts/sgf-internal-install.php', ...$args], $host, ['PG_RESTORE_BINARY' => 'C:/Program Files/PostgreSQL/17/bin/pg_restore.exe'], null, 60);
|
||||
$process->run();
|
||||
|
||||
return $process;
|
||||
};
|
||||
$plan = $cli(['plan', $host]);
|
||||
$plan->mustRun();
|
||||
$planResult = json_decode($plan->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
$check($planResult['target_sha256'] === $target && ! $planResult['schema_prepared'] && ! $db->getSchemaBuilder()->hasTable('sgc_installations'), 'CLI plan is read-only and identifies destination');
|
||||
$backup = sys_get_temp_dir().'/sgf-install-backup-'.bin2hex(random_bytes(6)).'.dump';
|
||||
$connection = $db->getConfig();
|
||||
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.$backup, '--dbname='.$db->getDatabaseName()], null,
|
||||
['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], null, 60);
|
||||
$dump->mustRun();
|
||||
$backupHash = hash_file('sha256', $backup);
|
||||
$invalidBackup = $cli(['migrate', $host, $target, $backup, str_repeat('0', 64), 'Synthetic operator', $planResult['manifest_sha256']]);
|
||||
$check(! $invalidBackup->isSuccessful() && str_contains($invalidBackup->getErrorOutput(), 'Backup ou manifesto'), 'CLI refuses unverified backup before schema write');
|
||||
$deny(fn () => Installation::migrate($db, str_repeat('0', 64), str_repeat('a', 64), 'Synthetic operator'), 'Destino, backup ou operador não conferidos.');
|
||||
$deny(fn () => DB::transaction(function () use ($db, $target) {
|
||||
$db->getSchemaBuilder()->drop('cb_audit');
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
|
||||
}), 'Schema orçamental parcial; não completar às cegas.');
|
||||
$deny(fn () => DB::transaction(function () use ($db, $target) {
|
||||
$db->statement('ALTER TABLE cb_budgets ADD COLUMN unexpected_field text');
|
||||
Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
|
||||
}), 'Schema orçamental incompatível com o contrato aceite.');
|
||||
try {
|
||||
DB::transaction(function () use ($db, $target, $check, $scope, $f) {
|
||||
foreach (array_reverse(Installation::TABLES) as $table) {
|
||||
$db->getSchemaBuilder()->drop($table);
|
||||
}
|
||||
DB::table('permission_grants')->where('permission', 'like', 'purchasing_budget.%')->delete();
|
||||
$db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit') AND unit_id IS NOT NULL))");
|
||||
$result = Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator');
|
||||
$check($result['prepared'] && DB::table('cb_budgets')->count() === 0, 'clean budget schema installed without synthetic records');
|
||||
DB::table('permission_grants')->insert($scope + ['user_id' => $f['actor'], 'permission' => 'purchasing_budget.view', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$check(true, 'clean identity catalogue supports budget rights without automatic grants');
|
||||
throw new RuntimeException('Restore isolated existing fixture');
|
||||
});
|
||||
} catch (RuntimeException $error) {
|
||||
if ($error->getMessage() !== 'Restore isolated existing fixture') {
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
$fail = true;
|
||||
DB::listen(function ($q) use (&$fail) {
|
||||
if ($fail && str_contains($q->sql, 'create table "ir_events"')) {
|
||||
$fail = false;
|
||||
throw new RuntimeException('Injected operational DDL failure');
|
||||
}
|
||||
});
|
||||
$deny(fn () => Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic operator'), 'Injected operational DDL failure');
|
||||
$check(! $db->getSchemaBuilder()->hasTable('ir_requests') && ! $db->getSchemaBuilder()->hasTable('sgc_installations'), 'failed installation leaves no partial module or receipt');
|
||||
$check((array) DB::table('cb_budgets')->first() === (array) $beforeBudget && DB::table('permission_grants')->count() === $beforeGrants, 'failed preparation preserves existing budget and rights');
|
||||
$migrate = $cli(['migrate', $host, $target, $backup, $backupHash, 'Synthetic deployment operator', $planResult['manifest_sha256']]);
|
||||
if (! $migrate->isSuccessful()) {
|
||||
throw new RuntimeException($migrate->getErrorOutput());
|
||||
}
|
||||
$result = json_decode($migrate->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
||||
$check($result['prepared'] && ! $result['activated'], 'schema prepared without code activation');
|
||||
$check(DB::table('sgc_installations')->value('backup_sha256') === $backupHash, 'CLI records actual verified custom backup hash');
|
||||
unlink($backup);
|
||||
$check(Installation::migrate($db, $target, str_repeat('a', 64), 'Synthetic deployment operator')['replayed'] && DB::table('sgc_installations')->count() === 1, 'installation retry preserves one receipt');
|
||||
$check(DB::table('permission_grants')->count() === $beforeGrants && (array) DB::table('cb_budgets')->first() === (array) $beforeBudget, 'successful preparation preserves grants and approved budget');
|
||||
$app->detectEnvironment(fn () => 'production');
|
||||
config(['purchasing-operational.enabled' => true, 'purchasing-operational.target' => $target, 'purchasing-operational.scopes' => [implode(':', $scope)]]);
|
||||
BudgetAdapter::guard();
|
||||
$check(true, 'operational composition works outside QA environment');
|
||||
$reservation = app(BudgetAdapter::class)->record(User::findOrFail($f['actor']), $f['origin'], 'reserve', 11600, 10000);
|
||||
$check($reservation !== null && DB::table('cb_budget_entries')->where('kind', 'reserve')->count() === 1, 'operational composition reuses existing budget reservation motor');
|
||||
DB::table('permission_grants')->where($scope)->where('user_id', $f['actor'])->where('permission', 'purchasing_budget.record')->delete();
|
||||
try {
|
||||
app(BudgetAdapter::class)->record(User::findOrFail($f['actor']), $f['origin'], 'release');
|
||||
throw new LogicException('Revoked right accepted');
|
||||
} catch (HttpException $error) {
|
||||
$check($error->getStatusCode() === 403, 'operational budget checks revoked permission');
|
||||
}
|
||||
config(['purchasing-operational.scopes' => []]);
|
||||
try {
|
||||
BudgetAdapter::guard();
|
||||
throw new LogicException('Unconfigured unit accepted');
|
||||
} catch (HttpException $error) {
|
||||
$check($error->getStatusCode() === 403, 'unconfigured operational unit refused');
|
||||
}
|
||||
config(['purchasing-operational.scopes' => [implode(':', $scope)]]);
|
||||
$db->statement('ALTER TABLE ir_requests ADD COLUMN unexpected_extension text');
|
||||
try {
|
||||
BudgetAdapter::guard();
|
||||
throw new LogicException('Divergent schema accepted');
|
||||
} catch (HttpException $error) {
|
||||
$check($error->getStatusCode() === 503, 'divergent schema refused without rewriting it');
|
||||
}
|
||||
$db->statement('ALTER TABLE ir_requests DROP COLUMN unexpected_extension');
|
||||
try {
|
||||
DB::transaction(fn () => DB::table('sgc_installations')->delete());
|
||||
throw new LogicException('Installation history deleted');
|
||||
} catch (QueryException $error) {
|
||||
$check(str_contains($error->getMessage(), 'Preserve installation history'), 'schema preparation history immutable');
|
||||
}
|
||||
$config = $db->getConfig();
|
||||
$dump = new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe', '--format=custom', '--file='.getenv('IR_OPERATIONAL_BACKUP'), '--dbname='.$db->getDatabaseName()], null,
|
||||
['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password']], null, 60);
|
||||
$dump->mustRun();
|
||||
echo json_encode(['passed' => true, 'checks' => $checks, 'installed_on_destination' => false, 'activated' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Purchasing\Economic\ProposalScoring;
|
||||
use Tempo\Purchasing\Economic\QuotationPricing;
|
||||
|
||||
require_once __DIR__.'/../../packages/purchasing/bootstrap.php';
|
||||
|
||||
function economicRow(int $id, int $price, int $quality = 8): array
|
||||
{
|
||||
return ['id' => $id, 'eligible' => true, 'subtotal_minor' => $price, 'delivery_days' => 2,
|
||||
'weights' => ['price' => 50, 'quality' => 50, 'delivery' => 0, 'history' => 0, 'payment' => 0],
|
||||
'evaluation_count' => 2, 'qualified_evaluations' => array_fill(0, 2, ['quality' => (string) $quality, 'history' => '8', 'payment' => '8'])];
|
||||
}
|
||||
|
||||
test('shared unit pricing preserves discounts and half up net unit cents', function () {
|
||||
$pricing = new QuotationPricing;
|
||||
expect($pricing->unit('19.99', 'percent', '12.50'))->toBe(['gross_unit_minor' => 1999, 'discount_type' => 'percent', 'discount_value' => '12.50', 'net_unit_minor' => 1749])
|
||||
->and($pricing->unit('0.03', 'percent', '50')['net_unit_minor'])->toBe(2)
|
||||
->and($pricing->unit('100', 'amount', '7.50')['net_unit_minor'])->toBe(9250)
|
||||
->and($pricing->unit('100')['net_unit_minor'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('shared pricing rejects invalid discounts without Laravel or a database', function () {
|
||||
foreach ([['0', 'none', '0'], ['100', 'percent', '101'], ['100', 'amount', '100'], ['100', 'none', '1'], ['100', 'unknown', '0']] as $input) {
|
||||
expect(fn () => (new QuotationPricing)->unit(...$input))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared scoring preserves exact weighted results all ties and host supplied ineligibility', function () {
|
||||
$rows = [economicRow(1, 10000), economicRow(2, 20000, 10), economicRow(3, 10000), [...economicRow(4, 1, 10), 'eligible' => false]];
|
||||
expect((new ProposalScoring)->rank($rows, 2))->toBe(['scores' => [1 => 900, 2 => 750, 3 => 900], 'top_ids' => [1, 3]]);
|
||||
});
|
||||
|
||||
test('shared scoring retains the existing reference basis when eligible rows lack sufficient evaluations', function () {
|
||||
$pending = economicRow(5, 5000);
|
||||
$pending['qualified_evaluations'] = [$pending['qualified_evaluations'][0]];
|
||||
$pending['evaluation_count'] = 1;
|
||||
expect((new ProposalScoring)->rank([economicRow(1, 10000), economicRow(2, 20000, 10), $pending], 2))->toBe(['scores' => [1 => 650, 2 => 625], 'top_ids' => [1]]);
|
||||
expect((new ProposalScoring)->rank([], 2))->toBe(['scores' => [], 'top_ids' => []]);
|
||||
});
|
||||
|
||||
test('shared scoring refuses inconsistent projected inputs rather than inventing a result', function () {
|
||||
$row = economicRow(1, 10000);
|
||||
foreach ([[$row, $row], [[...$row, 'subtotal_minor' => 0]], [[...$row, 'evaluation_count' => 3]], [[...$row, 'weights' => ['price' => 100]]]] as $rows) {
|
||||
expect(fn () => (new ProposalScoring)->rank($rows, 2))->toThrow(InvalidArgumentException::class);
|
||||
}
|
||||
expect(fn () => (new ProposalScoring)->rank([$row], 0))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Distribution\Release;
|
||||
|
||||
require_once __DIR__.'/../../packages/module-updates/Release.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->root = sys_get_temp_dir().'/sgc-update-test-'.bin2hex(random_bytes(8));
|
||||
mkdir($this->root, 0700);
|
||||
$this->source = $this->root.'/source';
|
||||
$this->channel = $this->root.'/channel';
|
||||
$this->host = $this->root.'/host';
|
||||
mkdir($this->source.'/src', 0700, true);
|
||||
mkdir($this->host, 0700);
|
||||
file_put_contents($this->host.'/composer.json', json_encode(['name' => 'tempo/sgf-tempo']));
|
||||
file_put_contents($this->host.'/business-data.sentinel', 'preserved');
|
||||
file_put_contents($this->source.'/bootstrap.php', '<?php // synthetic package');
|
||||
file_put_contents($this->source.'/src/Example.php', '<?php // first implementation');
|
||||
file_put_contents($this->source.'/module.json', json_encode(['package' => 'tempo/purchasing', 'host_contract' => 1, 'minimum_php' => '8.3.0', 'database_changes' => false, 'commercial_permissions' => false, 'capabilities' => ['synthetic.example']]));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$root = realpath($this->root);
|
||||
$temporary = str_replace('\\', '/', realpath(sys_get_temp_dir()));
|
||||
if ($root === false || ! str_starts_with(str_replace('\\', '/', $root), $temporary.'/sgc-update-test-')) {
|
||||
throw new LogicException('Cleanup outside generated test workspace rejected.');
|
||||
}
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
if ($file->isDir() && ! $file->isLink()) {
|
||||
rmdir($file->getPathname());
|
||||
} else {
|
||||
unlink($file->getPathname());
|
||||
}
|
||||
}
|
||||
rmdir($root);
|
||||
});
|
||||
|
||||
test('unchanged source publishes no duplicate and improvements create immutable patch versions', function () {
|
||||
$first = Release::publish($this->source, $this->channel);
|
||||
$same = Release::publish($this->source, $this->channel);
|
||||
expect($first['version'])->toBe('0.1.0')->and($same['changed'])->toBeFalse();
|
||||
$oldHash = hash_file('sha256', $this->channel.'/'.$first['archive']);
|
||||
file_put_contents($this->source.'/src/Example.php', '<?php // improved implementation');
|
||||
$next = Release::publish($this->source, $this->channel);
|
||||
expect($next['version'])->toBe('0.1.1')->and($next['source_sha256'])->not->toBe($first['source_sha256'])
|
||||
->and(hash_file('sha256', $this->channel.'/'.$first['archive']))->toBe($oldHash);
|
||||
});
|
||||
|
||||
test('installation update retry rollback and reactivation preserve host data and earlier versions', function () {
|
||||
$first = Release::publish($this->source, $this->channel);
|
||||
Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']);
|
||||
file_put_contents($this->source.'/src/Example.php', '<?php // improved implementation');
|
||||
$next = Release::publish($this->source, $this->channel);
|
||||
$installed = Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256']);
|
||||
expect($installed['version'])->toBe('0.1.1')->and($installed['previous'])->toBe('0.1.0');
|
||||
expect(Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256'])['changed'])->toBeFalse();
|
||||
expect(Release::rollback($this->host)['version'])->toBe('0.1.0');
|
||||
expect(Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256'])['version'])->toBe('0.1.1');
|
||||
expect(file_get_contents($this->host.'/business-data.sentinel'))->toBe('preserved')
|
||||
->and(is_file($this->host.'/packages/purchasing/releases/0.1.0/src/Example.php'))->toBeTrue();
|
||||
expect(fn () => Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('tampered installed code is not overwritten by an update', function () {
|
||||
$first = Release::publish($this->source, $this->channel);
|
||||
Release::install($this->host, $this->channel.'/'.$first['archive'], $first['manifest_sha256']);
|
||||
file_put_contents($this->host.'/packages/purchasing/releases/0.1.0/src/Example.php', '<?php // local modification');
|
||||
file_put_contents($this->source.'/src/Example.php', '<?php // new version');
|
||||
$next = Release::publish($this->source, $this->channel);
|
||||
$pointer = file_get_contents($this->host.'/packages/purchasing/current.json');
|
||||
expect(fn () => Release::install($this->host, $this->channel.'/'.$next['archive'], $next['manifest_sha256']))->toThrow(RuntimeException::class);
|
||||
expect(file_get_contents($this->host.'/packages/purchasing/current.json'))->toBe($pointer);
|
||||
});
|
||||
|
||||
test('archive rejects tampering traversal missing files and a wrong trusted manifest', function () {
|
||||
$first = Release::publish($this->source, $this->channel);
|
||||
$original = $this->channel.'/'.$first['archive'];
|
||||
expect(fn () => Release::inspect($original, str_repeat('0', 64)))->toThrow(RuntimeException::class);
|
||||
foreach (['tamper', 'traverse', 'missing'] as $mode) {
|
||||
$archive = $this->root.'/'.$mode.'.zip';
|
||||
copy($original, $archive);
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($archive);
|
||||
if ($mode === 'tamper') {
|
||||
$zip->addFromString('payload/src/Example.php', '<?php // tampered');
|
||||
} elseif ($mode === 'traverse') {
|
||||
$zip->addFromString('payload/../../outside.php', '<?php // outside');
|
||||
} else {
|
||||
$zip->deleteName('payload/src/Example.php');
|
||||
}
|
||||
$zip->close();
|
||||
expect(fn () => Release::install($this->host, $archive, $first['manifest_sha256']))->toThrow(RuntimeException::class);
|
||||
}
|
||||
expect(Release::current($this->host))->toBeNull()->and(is_file($this->root.'/outside.php'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('release refuses secrets data and schema permission changes', function () {
|
||||
file_put_contents($this->source.'/.env', 'SYNTHETIC_SECRET');
|
||||
expect(fn () => Release::publish($this->source, $this->channel))->toThrow(RuntimeException::class);
|
||||
unlink($this->source.'/.env');
|
||||
$module = json_decode(file_get_contents($this->source.'/module.json'), true);
|
||||
$module['database_changes'] = true;
|
||||
file_put_contents($this->source.'/module.json', json_encode($module));
|
||||
expect(fn () => Release::publish($this->source, $this->channel))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('client executes an installed artifact with its source directory unavailable', function () {
|
||||
file_put_contents($this->source.'/bootstrap.php', '<?php require_once __DIR__."/src/Example.php";');
|
||||
file_put_contents($this->source.'/src/Example.php', '<?php namespace StandaloneClient; final class Example { public static function answer(): int { return 42; } }');
|
||||
$release = Release::publish($this->source, $this->channel);
|
||||
Release::install($this->host, $this->channel.'/'.$release['archive'], $release['manifest_sha256']);
|
||||
mkdir($this->host.'/packages/module-updates', 0700);
|
||||
copy(__DIR__.'/../../packages/module-updates/Release.php', $this->host.'/packages/module-updates/Release.php');
|
||||
copy(__DIR__.'/../../packages/module-updates/purchasing-bootstrap.php', $this->host.'/packages/purchasing/bootstrap.php');
|
||||
rename($this->source, $this->source.'.unavailable');
|
||||
expect(is_dir($this->source))->toBeFalse();
|
||||
$code = 'require $argv[1]; echo json_encode(["answer"=>\StandaloneClient\Example::answer(),"files"=>get_included_files()]);';
|
||||
$process = new Process([PHP_BINARY, '-r', $code, $this->host.'/packages/purchasing/bootstrap.php'], $this->host);
|
||||
$process->mustRun();
|
||||
$result = json_decode($process->getOutput(), true, 32, JSON_THROW_ON_ERROR);
|
||||
expect($result['answer'])->toBe(42);
|
||||
$prefix = strtolower(str_replace('\\', '/', realpath($this->host))).'/';
|
||||
foreach ($result['files'] as $file) {
|
||||
expect(str_starts_with(strtolower(str_replace('\\', '/', realpath($file))), $prefix))->toBeTrue();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user