213 lines
14 KiB
PHP
213 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\CriticalityLevel;
|
|
use App\Models\Department;
|
|
use App\Models\Equipment;
|
|
use App\Models\User;
|
|
use App\Models\WarehouseRequestEvent;
|
|
use App\Services\OrganizationAccess;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\WarehouseExchange;
|
|
use App\Services\WarehouseFulfilment;
|
|
use App\Services\WarehouseRequests;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tempo\Integration\WarehouseInbox;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
require_once __DIR__.'/../Support/InboxFixture.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']);
|
|
$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->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['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]]]);
|
|
});
|
|
|
|
function exchangeCommand($test, $actor, string $action, array $extra = []): WarehouseRequestEvent
|
|
{
|
|
$test->actingAs($actor);
|
|
|
|
return app(WarehouseFulfilment::class)->command($actor, $test->r->id, [
|
|
'operation_key' => (string) Str::uuid(), 'version' => app(WarehouseFulfilment::class)->progress($test->r)['version'],
|
|
'action' => $action, 'reason' => 'Intervenção sintética do ensaio de integração.', ...$extra]);
|
|
}
|
|
|
|
function exchangeDeliver($test, string $quantity = '1.500'): WarehouseRequestEvent
|
|
{
|
|
exchangeCommand($test, $test->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
exchangeCommand($test, $test->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
|
|
return exchangeCommand($test, $test->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => $quantity]]]);
|
|
}
|
|
|
|
test('warehouse exchange publishes only physical facts with original identifiers and stable replay content', function () {
|
|
$url = '/stock/integracao/entregas';
|
|
$this->getJson($url)->assertOk()->assertJsonCount(0, 'items');
|
|
$delivery = exchangeDeliver($this);
|
|
$first = $this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
|
|
->assertJsonPath('schema', WarehouseExchange::SCHEMA)->assertJsonCount(1, 'items')
|
|
->assertJsonPath('items.0.lines.0.quantity_milli', 1500)
|
|
->assertJsonPath('items.0.lines.0.stock_operation_id', $delivery->data['lines'][0]['stock_operation_id'])
|
|
->assertJsonPath('items.0.request.document_type', 'internal_warehouse_request')
|
|
->assertJsonPath('items.0.request.destination', 'Oficina interna')->json('items.0');
|
|
$payload = $first;
|
|
unset($payload['payload_sha256']);
|
|
expect(hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)))->toBe($first['payload_sha256']);
|
|
// Current register changes must not rewrite the original item or requester.
|
|
$this->f['stock']->update(['name' => 'Nome posterior do cadastro']);
|
|
$this->f['issuer']->update(['name' => 'Nome posterior da pessoa']);
|
|
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
|
$response = $this->getJson($url)->assertOk()->assertJsonCount(2, 'items');
|
|
expect($response->json('items.0'))->toBe($first);
|
|
$response->assertJsonPath('items.1.action', 'receive')->assertJsonCount(0, 'items.1.lines')
|
|
->assertJsonPath('items.1.delivery_event_key', $first['idempotency_key']);
|
|
});
|
|
|
|
test('warehouse exchange resumes old pending pages and later polls without skipping or duplicating facts', function () {
|
|
$delivery = exchangeDeliver($this);
|
|
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
|
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->assertJsonPath('has_more', true)->json();
|
|
$next = '/stock/integracao/entregas?limit=1&cursor='.urlencode($first['next_cursor']);
|
|
exchangeCommand($this, $this->f['receiver'], 'deliver', ['lines' => [['index' => 0, 'quantity' => '2.500']]]);
|
|
$this->actingAs($this->f['issuer']);
|
|
$second = $this->getJson($next)->assertOk()->assertJsonPath('items.0.action', 'receive')->assertJsonPath('has_more', true)->json();
|
|
expect($second['items'][0]['event_id'])->toBeGreaterThan($first['items'][0]['event_id']);
|
|
$third = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($second['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('has_more', false)->json();
|
|
$empty = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($third['next_cursor']))->assertOk()->assertJsonCount(0, 'items')->json();
|
|
$last = WarehouseRequestEvent::query()->where('action', 'deliver')->latest('id')->firstOrFail();
|
|
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $last->id]);
|
|
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($empty['next_cursor']))->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.action', 'receive');
|
|
});
|
|
|
|
test('warehouse exchange reads leave business ledgers profiles and audit unchanged', function () {
|
|
exchangeDeliver($this);
|
|
$tables = ['stock_movements', 'stock_operations', 'warehouse_reservations', 'warehouse_requests', 'warehouse_request_events', 'audit_logs', 'requisitions', 'organization_memberships'];
|
|
$before = [];
|
|
foreach ($tables as $table) {
|
|
$before[$table] = DB::table($table)->get()->toArray();
|
|
}
|
|
$this->getJson('/stock/integracao/entregas')->assertOk();
|
|
$this->getJson('/stock/integracao/entregas')->assertOk();
|
|
foreach ($tables as $table) {
|
|
expect(DB::table($table)->get()->toArray())->toEqual($before[$table]);
|
|
}
|
|
});
|
|
|
|
test('warehouse exchange rejects corrupt cross user cursors invalid limits and revoked access', function () {
|
|
exchangeDeliver($this);
|
|
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
|
$this->getJson('/stock/integracao/entregas?cursor=invalid')->assertUnprocessable()->assertJsonValidationErrors('cursor');
|
|
$this->getJson('/stock/integracao/entregas?limit=101')->assertUnprocessable();
|
|
$this->getJson('/stock/integracao/entregas?limit=0')->assertUnprocessable();
|
|
$this->actingAs($this->f['issuer'])->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable();
|
|
DB::table('organization_memberships')->where('user_id', $this->f['issuer']->id)->delete();
|
|
$this->getJson('/stock/integracao/entregas')->assertForbidden();
|
|
});
|
|
|
|
test('warehouse exchange respects department ownership and tenant boundaries', function () {
|
|
exchangeDeliver($this);
|
|
$department = Department::create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
|
$outsider = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id]);
|
|
supplyGrant($outsider, ['warehouse_request_review', 'warehouse_request_prepare']);
|
|
$this->actingAs($outsider)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
|
$this->actingAs($this->reviewer)->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items');
|
|
$foreign = receiptFixture('50.000');
|
|
supplyGrant($foreign['issuer'], ['admin']);
|
|
$this->actingAs($foreign['issuer'])->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
|
});
|
|
|
|
test('warehouse exchange requires an authenticated account with functional access', function () {
|
|
auth('web')->logout();
|
|
$this->getJson('/stock/integracao/entregas')->assertUnauthorized();
|
|
$this->actingAs($this->f['otherReceiver'])->getJson('/stock/integracao/entregas')->assertForbidden();
|
|
});
|
|
|
|
test('warehouse exchange rejects old cursors when functional scope expands and publishes no mere reservation', function () {
|
|
exchangeCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
|
|
exchangeCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
|
|
$response = $this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
|
$cursor = $response->json('next_cursor');
|
|
supplyGrant($this->reviewer, ['warehouse_request_read']);
|
|
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertUnprocessable()->assertJsonValidationErrors('cursor');
|
|
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(0, 'items');
|
|
});
|
|
|
|
test('warehouse exchange never presents a cancelled undelivered balance as another physical issue', function () {
|
|
$delivery = exchangeDeliver($this);
|
|
exchangeCommand($this, $this->reviewer, 'cancel');
|
|
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')->assertJsonPath('items.0.event_id', $delivery->id);
|
|
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
|
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(2, 'items')->assertJsonPath('items.1.action', 'receive');
|
|
});
|
|
|
|
test('warehouse exchange refuses another context and invalidates cursors after context activation', function () {
|
|
exchangeDeliver($this);
|
|
$actor = $this->f['issuer'];
|
|
$this->actingAs($actor);
|
|
$legacyCursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
|
$this->f['tenant']->update(['rules_provisional' => false, 'work_context_policy' => ['revision' => 1, 'enabled' => ['procurement.internal', 'people.internal']]]);
|
|
config(['work_context.entitlements.'.$actor->tenant_id => ['procurement.internal', 'people.internal']]);
|
|
supplyGrant($actor, ['admin']);
|
|
DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->update(['work_contexts' => json_encode(['procurement.internal', 'people.internal'])]);
|
|
$this->post('/contexto', ['area' => 'people', 'option' => 'people.internal'])->assertRedirect();
|
|
$this->getJson('/stock/integracao/entregas')->assertForbidden();
|
|
$this->post('/contexto', ['area' => 'procurement', 'option' => 'procurement.internal'])->assertRedirect();
|
|
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($legacyCursor))->assertUnprocessable();
|
|
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonCount(1, 'items')
|
|
->assertJsonPath('items.0.request.work_context_key', 'procurement.internal');
|
|
});
|
|
|
|
test('warehouse exchange preserves the equipment selected at the request origin', function () {
|
|
$level = CriticalityLevel::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['issuer']->id,
|
|
'code' => 'NORMAL', 'name' => 'Normal', 'criteria' => 'Critério sintético.', '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->actingAs($this->f['issuer']);
|
|
$this->r = app(WarehouseRequests::class)->draft($this->f['issuer'], ['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' => 'equipment', 'equipment_id' => $equipment->id]]]);
|
|
exchangeDeliver($this, '4.000');
|
|
$equipment->update(['description' => 'Descrição futura']);
|
|
$this->getJson('/stock/integracao/entregas')->assertOk()->assertJsonPath('items.0.lines.0.equipment.id', $equipment->id)
|
|
->assertJsonPath('items.0.lines.0.equipment.description', 'Máquina da oficina')->assertJsonPath('items.0.lines.0.use', 'equipment');
|
|
});
|
|
|
|
test('warehouse delivery feed is accepted by an independent durable receiver without another stock movement', function () {
|
|
$delivery = exchangeDeliver($this);
|
|
exchangeCommand($this, $this->f['issuer'], 'receive', ['delivery_event_id' => $delivery->id]);
|
|
$before = DB::table('stock_movements')->count();
|
|
$db = inboxDatabase();
|
|
$inbox = new WarehouseInbox($db, 'synthetic-source', $this->f['tenant']->id, 99, 'synthetic-consumer');
|
|
$checkpoint = $inbox->checkpoint();
|
|
$first = $this->getJson('/stock/integracao/entregas?limit=1')->assertOk()->json();
|
|
$accepted = $inbox->accept($first, $checkpoint['version'], $checkpoint['cursor']);
|
|
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeTrue();
|
|
$checkpoint = $inbox->checkpoint();
|
|
$second = $this->getJson('/stock/integracao/entregas?cursor='.urlencode($checkpoint['cursor']))->assertOk()->json();
|
|
$accepted = $inbox->accept($second, $checkpoint['version'], $checkpoint['cursor']);
|
|
expect($accepted['inserted'])->toBe(1)->and($accepted['has_more'])->toBeFalse()
|
|
->and($inbox->accept($second, $checkpoint['version'], $checkpoint['cursor'])['replayed'])->toBeTrue()
|
|
->and($inbox->fact($first['items'][0]['idempotency_key']))->toBe($first['items'][0])
|
|
->and(DB::table('stock_movements')->count())->toBe($before);
|
|
});
|
|
|
|
test('warehouse feed cursor stays bounded even for a profile containing the whole permission catalogue', function () {
|
|
supplyGrant($this->f['issuer'], array_keys(OrganizationAccess::PERMISSIONS));
|
|
$this->actingAs($this->f['issuer']);
|
|
$cursor = $this->getJson('/stock/integracao/entregas')->assertOk()->json('next_cursor');
|
|
expect(strlen($cursor))->toBeLessThanOrEqual(4096);
|
|
$this->getJson('/stock/integracao/entregas?cursor='.urlencode($cursor))->assertOk()->assertJsonCount(0, 'items');
|
|
});
|