Accept isolated WORKSHOP-RESOURCE-RECORD-v1 delivery

Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
This commit is contained in:
2026-10-27 11:28:34 +02:00
parent 4276aca5c2
commit 21a91f2aff
47 changed files with 2774 additions and 6 deletions
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace Tests\Feature;
use App\Modules\Services\ServiceReceivables;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\ReceivablesFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServiceReceivablesTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private int $ob;
protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::create(); $this->login(); }
private function login(string $who='manager'): void {
$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
}
private function commandPost(string $path,array $data,?string $key=null) {
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/service-receivables'.$path,$data);
}
private function rev(): int { return isset($this->ob)?DB::table('sr_effects')->where('obligation_id',$this->ob)->count():0; }
private function proposal(string $kind,array $extra=[],?string $key=null) {
return $this->commandPost('/proposals',array_replace(['kind'=>$kind,'invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-MANUAL','external_id'=>(string)Str::uuid(),'revision'=>$this->rev()],isset($this->ob)?['obligation_id'=>$this->ob]:[],$extra),$key);
}
private function decide(int $id,?string $key=null,array $extra=[]) { return $this->commandPost('/'.$id.'/decision',array_replace(['version'=>1,'revision'=>$this->rev(),'decision'=>'approved','reason'=>'Confirmação sintética explícita.'],$extra),$key); }
private function open(?string $due=null): void {
$this->ob=$this->proposal('obligation',['due_date'=>$due])->assertOk()->json('id');
$this->login('reader'); $this->decide($this->ob)->assertOk(); $this->login();
}
private function receipt(string $amount): int {
$id=$this->proposal('receipt',['amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Origem artificial sem dinheiro.'])->assertOk()->json('id');
$this->login('reader'); $this->decide($id)->assertOk(); $this->login(); return $id;
}
private function allocate(int $id,string $amount,?string $key=null,?int $rev=null) { return $this->commandPost('/'.$id.'/allocate',['amount'=>$amount,'version'=>1,'revision'=>$rev??$this->rev()],$key); }
private function ledgerView() { return $this->getJson('/api/service-receivables')->assertOk(); }
private function correction(string $action,array $data=[]): int { return $this->proposal('correction',['action'=>$action,'reason'=>'Correcção sintética justificada.']+$data)->assertOk()->json('id'); }
private function observation(int $receipt,string $amount): int {
return $this->proposal('observation',['receipt_id'=>$receipt,'amount'=>$amount,'date'=>now('Africa/Johannesburg')->format('Y-m-d'),'evidence'=>'Observação artificial independente.'])->assertOk()->json('id');
}
public function test_partial_receipts_exact_allocation_idempotency_and_independent_reconciliation(): void {
$before=DB::table('si_invoices')->get()->toJson(); $this->open(); $a=$this->receipt('400.00');
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00')->assertJsonPath('obligations.0.due_state','unknown');
$revision=$this->rev(); $key=(string)Str::uuid(); $id=$this->allocate($a,'400.00',$key,$revision)->assertOk()->json('id');
$this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->allocate($a,'399.99',$key,$revision)->assertStatus(409);
$b=$this->receipt('600'); $this->allocate($b,'600')->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.received','1000.00')->assertJsonPath('obligations.0.allocated','1000.00');
$oa=$this->observation($a,'400'); $ob=$this->observation($b,'600');
$this->login('reader'); $this->decide($oa)->assertOk(); $this->decide($ob)->assertOk();
$v=$this->ledgerView(); $receipts=$v->json('obligations.0.receipts'); $this->assertTrue($receipts[$a]['reconciled']); $this->assertTrue($receipts[$b]['reconciled']);
$this->assertSame($before,DB::table('si_invoices')->get()->toJson());
$this->assertSame(DB::table('sr_commands')->count(),DB::table('sr_events')->count());
$this->assertSame(DB::table('sr_events')->count(),DB::table('sr_outbox')->count());
$svc=app(ServiceReceivables::class); $event=DB::table('sr_events')->value('id'); $this->assertTrue($svc->deliver($event)); $this->assertFalse($svc->deliver($event));
}
public function test_excess_divergence_no_tolerance_and_duplicate_source(): void {
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('700');
$this->allocate($b,'700')->assertStatus(409); $this->ledgerView()->assertJsonPath('obligations.0.balance','600.00');
$this->allocate($b,'600')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.available','100.00')->assertJsonPath('obligations.0.balance','0.00');
$o=$this->observation($a,'399.99'); $this->login('reader'); $this->decide($o)->assertOk()->assertJsonPath('event','divergence.simulation');
$this->assertSame('0.01',DB::table('sr_effects')->where('kind','divergence')->value('amount'));
$this->login(); $r=DB::table('sr_records')->find($b);
$this->proposal('receipt',['source'=>$r->source,'external_id'=>$r->external_id,'amount'=>'700','date'=>now()->format('Y-m-d'),'evidence'=>'Repeated source'])->assertStatus(409);
}
public function test_reduction_reversal_dependencies_and_preserved_original(): void {
$this->open(); $a=$this->receipt('400'); $af=$this->allocate($a,'400')->assertOk()->json('id');
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-100','line'=>'1']);
$this->login('reader'); $this->decide($c)->assertOk(); $this->login();
$b=$this->receipt('500'); $bf=$this->allocate($b,'500')->assertOk()->json('id');
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.original','1000.00')->assertJsonPath('obligations.0.reduced','100.00');
$fail=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-200','line'=>'1']);
$this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
$fail=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($fail)->assertStatus(409); $this->login();
$undo=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $this->decide($undo)->assertOk(); $this->login();
$this->ledgerView()->assertJsonPath('obligations.0.balance','500.00');
$undo=$this->correction('reverse_receipt',['receipt_id'=>$b]); $this->login('reader'); $this->decide($undo)->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.received','400.00')->assertJsonPath('obligations.0.available','0.00');
$this->assertDatabaseHas('sr_effects',['id'=>$bf,'kind'=>'allocate','amount'=>'500.00']);
}
public function test_permissions_revocation_self_approval_stale_versions_and_cross_scope(): void {
$this->open(); $a=$this->receipt('400');
$this->login('reader'); $this->allocate($a,'400')->assertForbidden(); $this->proposal('receipt',[])->assertForbidden();
$this->login(); IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_receivables.confirm',$this->f['unit']);
$r=$this->proposal('receipt',['amount'=>'100','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertOk()->json('id');
$this->decide($r)->assertForbidden(); $this->allocate($a,'400',null,0)->assertStatus(409);
$this->commandPost('/'.$a.'/allocate',['amount'=>'400','revision'=>$this->rev(),'version'=>2])->assertUnprocessable();
$c=$this->correction('due',['due_date'=>'2026-11-10']); $this->allocate($a,'1')->assertOk(); $this->login('reader'); $this->decide($c)->assertStatus(409); $this->login();
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_receivables.allocate')->delete();
$this->allocate($a,'1')->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
$this->commandPost('/'.$a.'/allocate',['amount'=>'1','version'=>1,'revision'=>0])->assertStatus(409);
}
public function test_invalid_money_environment_and_sql_immutability(): void {
$this->open();
foreach(['0','-1','1.001','1e3','10000000000000000'] as $amount) $this->proposal('receipt',['amount'=>$amount,'date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic'])->assertUnprocessable();
$this->proposal('receipt',['amount'=>'1','currency'=>'USD'])->assertUnprocessable();
foreach(['sr_records','sr_effects','sr_events','sr_outbox','sr_commands'] as $table) {
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('Immutable deletion'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->proposal('obligation')->assertForbidden();
$this->ledgerView()->assertJsonPath('blocked',true)->assertJsonPath('obligations',[]);
try { ServiceReceivables::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
public function test_outbox_failure_rolls_back_and_lost_response_replay_recovers(): void {
$key=(string)Str::uuid(); $input=['kind'=>'obligation','invoice_id'=>$this->f['invoice'],'currency'=>'MZN','source'=>'TEST-FAIL','external_id'=>'failure-1','revision'=>0];
DB::unprepared("CREATE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
$this->commandPost('/proposals',$input,$key)->assertStatus(500); $this->assertDatabaseCount('sr_records',0); $this->assertDatabaseCount('sr_commands',0); $this->assertDatabaseCount('sr_events',0);
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
$id=$this->commandPost('/proposals',$input,$key)->assertOk()->json('id');
$this->commandPost('/proposals',$input,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true); $this->assertDatabaseCount('sr_records',1);
}
private function sqlDenied(callable $write): void {
try { DB::transaction($write); $this->fail('SQL invariant bypass'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
public function test_sql_protects_linkage_scale_aggregate_and_history_without_eloquent(): void {
$this->open(); $a=$this->receipt('700'); $this->allocate($a,'700')->assertOk(); $b=$this->receipt('400');
$row=(array)DB::table('sr_effects')->where('kind','allocate')->first(); unset($row['id']);
$row['record_id']=$b; $row['receipt_id']=$b; $row['amount']='400.00'; $row['revision']=$this->rev();
$this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
$row['amount']='0.001';
try { DB::transaction(fn()=>DB::table('sr_effects')->insert($row)); $this->fail('Scale silently rounded'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('check constraint',$e->getMessage()); }
$row['amount']='1.00'; $row['obligation_id']=999999; $this->sqlDenied(fn()=>DB::table('sr_effects')->insert($row));
$r=(array)DB::table('sr_records')->find($a); unset($r['id']); $r['external_id']='sql-transfer'; $r['unit_id']=$this->f['hidden'];
$this->sqlDenied(fn()=>DB::table('sr_records')->insert($r));
$this->sqlDenied(fn()=>DB::table('sr_records')->where('id',$a)->update(['client_id'=>$this->f['client']+1]));
$this->ledgerView()->assertJsonPath('obligations.0.balance','300.00')->assertJsonPath('obligations.0.allocated','700.00');
}
public function test_reconciliation_undo_reuse_and_post_settlement_reduction(): void {
$this->open(); $a=$this->receipt('400'); $this->allocate($a,'400')->assertOk(); $b=$this->receipt('600'); $bf=$this->allocate($b,'600')->assertOk()->json('id');
$ob=$this->observation($b,'600'); $this->login('reader'); $cf=$this->decide($ob)->assertOk()->json('id');
$this->decide($ob)->assertStatus(409); $this->login();
$other=$this->observation($b,'600'); $this->login('reader'); $this->decide($other)->assertStatus(409); $this->login();
$reverse=$this->correction('reverse_reconcile',['target_effect_id'=>$cf]); $this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$bf]); $this->login('reader'); $key=(string)Str::uuid(); $rev=$this->rev(); $this->decide($reverse,$key)->assertOk();
$this->decide($reverse,$key,['revision'=>$rev])->assertOk()->assertJsonPath('replayed',true); $this->login();
$c=$this->correction('reduction',['amount'=>'100','document_ref'=>'TEST-REDUCTION-AFTER','line'=>'1']); $this->login('reader'); $this->decide($c)->assertOk(); $this->login();
$this->allocate($b,'500')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.balance','0.00')->assertJsonPath('obligations.0.available','100.00');
$this->login('reader'); $this->decide($other)->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.balance','0.00');
}
public function test_due_boundaries_missing_dates_and_consumer_failure_are_explicit(): void {
$this->open('2026-11-10');
$this->travelTo(\Carbon\Carbon::parse('2026-11-10 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','not_due');
$this->travelTo(\Carbon\Carbon::parse('2026-11-11 12:00:00','Africa/Johannesburg')); $this->ledgerView()->assertJsonPath('obligations.0.due_state','overdue'); $this->travelBack();
$a=$this->receipt('1000'); $this->allocate($a,'1000')->assertOk(); $this->ledgerView()->assertJsonPath('obligations.0.due_state','settled');
$event=DB::table('sr_events')->value('id');
DB::unprepared("CREATE OR REPLACE FUNCTION sr_delivery_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_DELIVERY_FAILURE'; END; $$; CREATE TRIGGER sr_delivery_failure BEFORE INSERT ON sr_deliveries FOR EACH ROW EXECUTE FUNCTION sr_delivery_fail()");
try { app(ServiceReceivables::class)->deliver($event); $this->fail('Failure not injected'); } catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('TEST_DELIVERY_FAILURE',$e->getMessage()); }
$this->assertDatabaseCount('sr_deliveries',0); DB::unprepared('DROP TRIGGER sr_delivery_failure ON sr_deliveries');
$this->assertTrue(app(ServiceReceivables::class)->deliver($event)); $this->assertFalse(app(ServiceReceivables::class)->deliver($event));
}
public function test_foreign_identifiers_missing_evidence_and_db_target_guard(): void {
$this->open();
$this->proposal('receipt',['amount'=>'1'])->assertUnprocessable();
$this->proposal('receipt',['amount'=>'1','date'=>'2099-01-01','evidence'=>'Synthetic'])->assertUnprocessable();
$this->proposal('observation',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','receipt_id'=>999999])->assertForbidden();
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','invoice_id'=>999999])->assertForbidden();
$this->proposal('receipt',['amount'=>'1','date'=>now()->format('Y-m-d'),'evidence'=>'Synthetic','client_id'=>999999])->assertUnprocessable();
$a=$this->receipt('100'); $target=$this->allocate($a,'100')->assertOk()->json('id');
$reverse=$this->correction('reverse_allocation',['target_effect_id'=>$target]);
$this->login('reader'); $this->decide($reverse)->assertOk(); $this->login();
$again=$this->correction('reverse_allocation',['target_effect_id'=>$target]); $this->login('reader'); $this->decide($again)->assertStatus(409);
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','100.00');
}
public function test_financial_effect_rollback_live_scope_and_wrong_database_guard(): void {
$this->open(); $a=$this->receipt('400'); $revision=$this->rev(); $before=DB::table('sr_effects')->count(); $key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION sr_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER sr_failure BEFORE INSERT ON sr_outbox FOR EACH ROW EXECUTE FUNCTION sr_test_fail()");
$this->allocate($a,'400',$key,$revision)->assertStatus(500); $this->assertDatabaseCount('sr_effects',$before);
$this->ledgerView()->assertJsonPath('obligations.0.balance','1000.00')->assertJsonPath('obligations.0.available','400.00');
DB::unprepared('DROP TRIGGER sr_failure ON sr_outbox');
$this->allocate($a,'400',$key,$revision)->assertOk(); $this->allocate($a,'400',$key,$revision)->assertOk()->assertJsonPath('replayed',true);
foreach(['view','allocate'] as $right) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'service_receivables.'.$right,$this->f['siblingUnit']);
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
$this->allocate($a,'1')->assertForbidden(); $this->ledgerView()->assertJsonPath('records',[]);
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden();
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
try { ServiceReceivables::guard(); $this->fail('Wrong test destination allowed'); } catch(\RuntimeException $e) { $this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage()); }
}
public function test_proposed_refused_obligation_is_not_open_and_original_identity_is_frozen(): void {
$this->ob=$this->proposal('obligation')->assertOk()->json('id');
$this->ledgerView()->assertJsonPath('obligations.0.opened',false)->assertJsonPath('obligations.0.due_state','not_open');
$data=json_decode(DB::table('sr_records')->find($this->ob)->data,true);
$this->assertSame(hash('sha256',DB::table('si_invoices')->find($this->f['invoice'])->snapshot),$data['origin']['snapshot_sha256']);
$this->login('reader'); $this->decide($this->ob,null,['decision'=>'refused'])->assertOk();
$this->ledgerView()->assertJsonPath('obligations.0.state','refused')->assertJsonPath('obligations.0.opened',false);
$this->login(); $this->proposal('receipt',['amount'=>'400','date'=>now()->format('Y-m-d'),'evidence'=>'No open obligation'])->assertStatus(409);
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\WorkshopFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class WorkshopTest extends TestCase
{
use RefreshDatabase;
private array $f; private string $token; private int $order; private int $task; private int $auth;
protected function setUp(): void { parent::setUp(); $this->f=WorkshopFixture::create(); $this->login(); }
private function login(string $who='manager'): void {
$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
}
private function send(string $path,array $input,?string $key=null) {
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop'.$path,$input);
}
private function rev(): int { return isset($this->order)?(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$this->order])->v:0; }
private function openOrder(?string $reference=null): void {
$this->order=$this->send('/orders',['reference'=>$reference??'OT-TEST-'.Str::uuid(),'client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-'.Str::uuid(),'equipment_description'=>'PC sintético','serial'=>'SER-TEST','request'=>'Diagnóstico sintético','location'=>'Local sintético','evidence'=>'Pedido artificial.'])->assertOk()->json('id');
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertOk();
$this->login('technician'); $this->task=$this->record('task',['description'=>'Reparação sintética autorizável.'])->assertOk()->json('id');
}
private function record(string $kind,array $data=[],?string $key=null) {
return $this->send('/orders/'.$this->order.'/entries',array_replace(['kind'=>$kind,'origin'=>$kind.'-'.Str::uuid(),'version'=>1,'revision'=>$this->rev()],$data),$key);
}
private function authorize(): void {
$this->login(); $this->auth=$this->record('authorization',['task_id'=>$this->task,'authorized_by'=>'Responsável do cliente sintético','capacity'=>'Representante declarado de teste','authorized_at'=>now()->subMinute()->toIso8601String(),'channel'=>'Documento de teste','scope'=>'Apenas a tarefa sintética identificada.','evidence'=>'TEST-AUTH-1'])->assertOk()->json('id'); $this->login('technician');
}
private function verifyEntry(int $id,string $decision='verified',?string $key=null,array $changes=[]) {
$e=DB::table('wo_entries')->find($id);
return $this->send('/entries/'.$id.'/verification',array_replace(['version'=>$e->version,'revision'=>$this->rev(),'decision'=>$decision,'reason'=>'Revisão sintética explícita.'],$changes),$key);
}
private function ledger() { return $this->getJson('/api/workshop')->assertOk(); }
public function test_authorized_work_time_materials_and_distinct_verification(): void {
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 08:00:00',WorkshopWorkflow::ZONE)); $this->openOrder();
$this->record('start',['task_id'=>$this->task,'evidence'=>'Tentativa sem autorização.'])->assertStatus(409);
$this->authorize(); $this->travelTo(\Carbon\Carbon::parse('2026-10-27 09:00:00',WorkshopWorkflow::ZONE));
$start=$this->record('start',['task_id'=>$this->task,'authorization_id'=>$this->auth,'evidence'=>'Início explícito.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 11:00:00',WorkshopWorkflow::ZONE));
$time=$this->record('time',['task_id'=>$this->task,'authorization_id'=>$this->auth,'start_id'=>$start,'starts_at'=>'2026-10-27T09:00:00+02:00','ends_at'=>'2026-10-27T11:00:00+02:00','mode'=>'started','time_type'=>'work','pauses'=>[['start'=>'2026-10-27T10:00:00+02:00','end'=>'2026-10-27T10:15:00+02:00']],'evidence'=>'Trabalho sintético.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
$ids=[$this->task,$time];
foreach(['delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$qty) $ids[]=$this->record('material',['task_id'=>$this->task,'authorization_id'=>$this->auth,'starts_at'=>now()->toIso8601String(),'resource'=>'CABO-TEST','unit'=>'UN','owner'=>'company','event'=>$event,'quantity'=>$qty,'evidence'=>'Material apenas observado.'])->assertOk()->json('id');
$this->ledger()->assertJsonPath('orders.0.minutes_reported',105)->assertJsonPath('orders.0.minutes_verified',0)->assertJsonPath('orders.0.materials.0.unexplained','0');
$this->verifyEntry($time)->assertForbidden();
$this->login('reviewer'); foreach($ids as $id) $this->verifyEntry($id)->assertOk();
$this->ledger()->assertJsonPath('orders.0.minutes_verified',105)->assertJsonPath('orders.0.state','records_verified')->assertJsonPath('orders.0.materials.0.verified_consumed','2');
$this->assertSame(DB::table('wo_commands')->count(),DB::table('wo_events')->count()); $this->assertSame(DB::table('wo_events')->count(),DB::table('wo_outbox')->count()); $this->travelBack();
}
public function test_unauthorized_past_occurrence_is_auditable_never_retroactively_authorized(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>now()->subHours(2)->toIso8601String(),'ends_at'=>now()->subHour()->toIso8601String(),'mode'=>'reported','time_type'=>'work','evidence'=>'Ocorrência já acontecida sem cobertura.'];
$id=$this->record('time',$d)->assertOk()->assertJsonPath('outside_authority',true)->json('id');
$this->authorize(); $this->assertTrue(DB::table('wo_entries')->find($id)->outside_authority);
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($id,'acknowledged')->assertOk();
$this->ledger()->assertJsonPath('orders.0.state','exception_review')->assertJsonPath('orders.0.minutes_verified',0);
$this->login('technician'); $r=DB::table('wo_entries')->find($id);
$new=$this->record('time',$d+['previous_id'=>$id,'version'=>2,'origin'=>$r->origin,'correction_reason'=>'Precisão do relato, sem apagar excepção.'])->assertOk()->assertJsonPath('outside_authority',true)->json('id');
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($new)->assertStatus(409);
}
public function test_scope_change_requires_new_authorization_and_exact_version(): void {
$this->openOrder(); $this->authorize(); $task=DB::table('wo_entries')->find($this->task);
$new=$this->record('task',['description'=>'Trabalho adicional fora do primeiro âmbito.','previous_id'=>$task->id,'version'=>2,'origin'=>$task->origin,'correction_reason'=>'Cliente pediu alteração.'])->assertOk()->json('id');
$this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Autorização antiga não serve.'])->assertStatus(409);
$this->record('start',['task_id'=>$task->id,'authorization_id'=>$this->auth,'evidence'=>'Tarefa antiga.'])->assertStatus(409);
$this->task=$new; $this->authorize(); $this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Novo âmbito autorizado.'])->assertOk();
$this->login('reviewer'); $this->verifyEntry($task->id)->assertStatus(409); $this->verifyEntry($new,'refused')->assertOk();
}
public function test_overlap_invalid_pauses_units_and_permissions(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Apontamento retrospectivo.'];
$this->record('time',$d)->assertOk(); $this->record('time',$d)->assertStatus(409);
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T11:00:00+02:00','ends_at'=>'2026-10-26T12:00:00+02:00']))->assertOk();
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T13:00:00+02:00','ends_at'=>'2026-10-26T14:00:00+02:00','pauses'=>[['start'=>'2026-10-26T13:10:00+02:00','end'=>'2026-10-26T14:10:00+02:00']]]))->assertUnprocessable();
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T14:00:00+02:00','ends_at'=>'2026-10-26T14:00:30+02:00']))->assertUnprocessable();
$m=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','resource'=>'CABO','unit'=>'UN','owner'=>'company','event'=>'required','quantity'=>'3','evidence'=>'Necessidade, não consumo.'];
$this->record('material',$m)->assertOk(); $this->record('material',array_replace($m,['unit'=>'KG']))->assertStatus(409); $this->record('material',array_replace($m,['event'=>'reserved']))->assertUnprocessable();
$this->login('reader'); $this->getJson('/api/workshop')->assertForbidden();
$this->login('manager'); $this->record('task',['description'=>'Abertura não é execução.'])->assertForbidden();
}
public function test_idempotency_failures_revocation_and_real_guard(): void {
$this->openOrder(); $d=['kind'=>'task','description'=>'Tarefa idempotente','origin'=>'TEST-REPEAT','version'=>1,'revision'=>$this->rev()]; $key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
$before=DB::table('wo_entries')->count(); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertStatus(500); $this->assertDatabaseCount('wo_entries',$before);
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');
$id=$this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->json('id'); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->send('/orders/'.$this->order.'/entries',array_replace($d,['description'=>'Different']),$key)->assertStatus(409);
$this->record('task',['description'=>'Duplicate source','origin'=>'TEST-REPEAT'])->assertStatus(409);
DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'workshop.record'])->delete(); $this->record('task',['description'=>'Revogado'])->assertForbidden();
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->record('task',[])->assertForbidden();
try { WorkshopWorkflow::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
private function sqlDenied(callable $write): void {
try { DB::transaction($write); $this->fail('Database invariant bypass'); }
catch(\Illuminate\Database\QueryException $e) { $this->assertMatchesRegularExpression('/SGF_(WO_|APPEND_ONLY_AUDIT)/',$e->getMessage()); }
}
public function test_sql_immutability_versions_overlap_and_distinct_verifier(): void {
$this->openOrder(); $d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de auditoria.'];
$id=$this->record('time',$d)->assertOk()->json('id');
$this->sqlDenied(fn()=>DB::table('wo_entries')->where('id',$id)->update(['outside_authority'=>false]));
foreach(['wo_entries','wo_orders','wo_equipment','wo_commands','wo_events','wo_outbox'] as $table) $this->sqlDenied(fn()=>DB::table($table)->delete());
$row=(array)DB::table('wo_entries')->find($id); unset($row['id']); $row['origin']='SQL-OVERLAP';$row['revision']=$this->rev();
$this->sqlDenied(fn()=>DB::table('wo_entries')->insert($row));
$row=(array)DB::table('wo_orders')->find($this->order);unset($row['id']);$row['unit_id']=$this->f['hidden'];$row['reference']='SQL-CROSS';
// Same equipment in another unit is not a transfer of an existing order: creation must remain an authorized server command.
$this->sqlDenied(fn()=>DB::table('wo_orders')->where('id',$this->order)->update(['unit_id'=>$this->f['hidden']]));
IdentityFixture::grant($this->f['technician']->id,$this->f['tenant'],$this->f['company'],'workshop.verify',$this->f['unit']);
$this->verifyEntry($id,'acknowledged')->assertForbidden();
$this->sqlDenied(fn()=>DB::table('wo_decisions')->insert(['entry_id'=>$id,'entry_version'=>1,'revision'=>$this->rev(),'decision'=>'acknowledged','reason'=>'SQL self decision.','actor_id'=>$this->f['technician']->id,'created_at'=>now()]));
}
public function test_correction_replaces_current_view_not_verified_history(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato sem autorização.','origin'=>'CORRECTION-ORIGIN'];
$id=$this->record('time',$d)->assertOk()->json('id'); $this->login('reviewer'); $this->verifyEntry($id,'refused')->assertOk();$this->login('technician');
$next=$this->record('time',array_replace($d,['previous_id'=>$id,'version'=>2,'ends_at'=>'2026-10-26T10:00:00+02:00','correction_reason'=>'Corrigir hora final conforme evidência.']))->assertOk()->json('id');
$this->ledger()->assertJsonPath('orders.0.minutes_reported',60)->assertJsonPath('orders.0.minutes_verified',0);
$this->assertDatabaseHas('wo_entries',['id'=>$id,'minutes'=>120]);$this->assertDatabaseHas('wo_decisions',['entry_id'=>$id,'decision'=>'refused']);
$this->login('reviewer');$this->verifyEntry($id)->assertStatus(409);$this->verifyEntry($next,'acknowledged')->assertOk();
}
public function test_isolation_assignment_change_context_and_wrong_database(): void {
$this->openOrder(); $this->login();
foreach(['view','open','assign','authorize'] as $r) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'workshop.'.$r,$this->f['siblingUnit']);
$old=$this->token;
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertForbidden();$this->ledger()->assertJsonPath('orders',[]);
$this->withHeader('X-SGF-Context',$old)->postJson('/api/workshop/orders/'.$this->order.'/entries',[])->assertStatus(409);
$this->login('technician'); DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'unit.view','unit_id'=>$this->f['unit']])->delete();
$this->getJson('/api/workshop')->assertForbidden();
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
try { WorkshopWorkflow::guard();$this->fail('Wrong database'); } catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}
}
public function test_two_technicians_can_work_simultaneously_but_not_one_technician_twice(): void {
$this->openOrder();
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de dois técnicos distintos.'];
$this->record('time',$d)->assertOk();
foreach(['view','record'] as $right) IdentityFixture::grant($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],'workshop.'.$right,$this->f['unit']);
$this->login();$this->record('assignment',['technician_id'=>$this->f['reviewer']->id])->assertOk();
$this->login('technician');$this->record('time',$d)->assertForbidden();
$this->login('reviewer');$this->record('time',$d)->assertOk();$this->ledger()->assertJsonPath('orders.0.minutes_reported',240);
$this->record('time',$d)->assertStatus(409);
}
public function test_open_replay_and_material_correspondence_are_not_stock(): void {
$this->openOrder();
foreach(['required'=>'4','delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$q) $this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>$event,'quantity'=>$q,'owner'=>'client','unit'=>'UN','starts_at'=>now()->subMinute()->toIso8601String(),'evidence'=>'Material do cliente, observado apenas.'])->assertOk();
$this->ledger()->assertJsonPath('orders.0.materials.0.required','4')->assertJsonPath('orders.0.materials.0.consumed','2')->assertJsonPath('orders.0.materials.0.unexplained','0');
$this->assertDatabaseCount('sr_records',0);$this->assertDatabaseCount('si_invoices',0);
$this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>'consumed','quantity'=>'0','owner'=>'client','unit'=>'UN','starts_at'=>now()->toIso8601String(),'evidence'=>'Zero não é consumo.'])->assertUnprocessable();
}
public function test_order_creation_failure_is_atomic_and_replay_does_not_duplicate_equipment(): void {
$d=['reference'=>'OT-ATOMIC-OPEN','client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-ATOMIC','equipment_description'=>'Equipamento sintético','request'=>'Pedido de teste','location'=>'Local de teste','evidence'=>'Evidência artificial.'];$key=(string)Str::uuid();
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
$this->send('/orders',$d,$key)->assertStatus(500);$this->assertDatabaseCount('wo_orders',0);$this->assertDatabaseCount('wo_equipment',0);$this->assertDatabaseCount('wo_commands',0);
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');$id=$this->send('/orders',$d,$key)->assertOk()->json('id');
$this->send('/orders',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->send('/orders',$d)->assertStatus(409);$this->assertDatabaseCount('wo_orders',1);$this->assertDatabaseCount('wo_equipment',1);
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
final class InvoiceFixture
{
public static function create(?array $f=null): array
public static function create(?array $f=null,string $unitPrice='7850.00'): array
{
TestingDatabaseGuard::assertSafe(); $f ??= DocumentPreviewFixture::create();
$actor=$f['manager']->id; $validator=$f['reader']->id;
@@ -27,7 +27,7 @@ final class InvoiceFixture
$client=$w->save('clients',null,$actor,['name'=>'Cliente sintético Horizonte'],(string)Str::uuid())['id'];
$service=$w->save('services',null,$actor,['name'=>'Alojamento mensal sintético'],(string)Str::uuid())['id'];
$today=now(ServiceInvoicing::ZONE)->format('Y-m-d'); $start=now(ServiceInvoicing::ZONE)->subDays(30)->format('Y-m-d'); $end=now(ServiceInvoicing::ZONE)->subDay()->format('Y-m-d');
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'7850.00','description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>$unitPrice,'description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Factura de TESTE — alojamento'],(string)Str::uuid())['id'];
$prepare->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$start,'period_end'=>$end,'units'=>['período'],'payment_terms'=>'Condição sintética: vencimento na data de emissão.','proposed_due_date'=>$today,'payment_instruction_ids'=>[]]);
foreach(['issuer'=>$f['company'],'client'=>$client] as $kind=>$id) $prepare->save($kind,$id,$actor,['version'=>0,'nuit'=>$kind==='issuer'?'999999991':'999999992','address'=>'Rua de demonstração, 1','locality'=>'Localidade sintética','country'=>'Moçambique']);
@@ -43,12 +43,12 @@ final class InvoiceFixture
$select($actor);
return $f+compact('client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end');
}
public static function additionalDraft(array $f): array
public static function additionalDraft(array $f,string $unitPrice='7850.00'): array
{
TestingDatabaseGuard::assertSafe(); $actor=$f['manager']->id; $validator=$f['reader']->id;
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
$select($actor); $w=app(ServiceWorkflow::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>'7850.00']]],(string)Str::uuid())['id'];
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>$unitPrice]]],(string)Str::uuid())['id'];
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Documento sintético concorrente'],(string)Str::uuid())['id'];
app(DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$f['start'],'period_end'=>$f['end'],'units'=>['período'],'payment_instruction_ids'=>[]]);
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-'.$draft,'data'=>['profile_proposal_id'=>$f['profile'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$f['start'],'reference_basis'=>'Referência explícita sintética.','period_start'=>$f['start'],'period_end'=>$f['end'],'event'=>'pending','evidence'=>'Prova concorrente isolada.']])['id'];
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Tests\Support;
use App\Modules\Services\ServiceInvoicing;
use App\Modules\Services\ServiceReceivables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Str;
final class ReceivablesFixture
{
public static function create(?array $base=null): array
{
ServiceReceivables::guard();
// Existing approved calculation path: 862.07 + trunc(137.9312,2) = 1000.00.
// Only a synthetic parameter choice; never infers tax treatment from the receivable amount.
$f=InvoiceFixture::create($base,'862.07');
foreach(['manager'=>['view','prepare','allocate','correction_request'],'reader'=>['view','confirm','reconcile','correction_approve']] as $who=>$rights)
foreach($rights as $right) IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'service_receivables.'.$right,$f['unit']);
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);
$si=app(ServiceInvoicing::class); $review=$si->review($f['draft'],$f['series'],$f['rule'],$f['manager']->id);
$invoice=$si->issue($f['draft'],$f['manager']->id,['series_id'=>$f['series'],'rule_id'=>$f['rule'],'review_hash'=>$review['review_hash']],(string)Str::uuid())['id'];
return $f+['invoice'=>$invoice];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Tests\Support;
use App\Models\User;
use App\Modules\Services\ServiceWorkflow;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class WorkshopFixture
{
public static function create(?array $base=null): array
{
WorkshopWorkflow::guard(); $f=$base??DocumentPreviewFixture::create();
$tech=User::create(['name'=>'Técnico Sintético Oficina','email'=>'tecnico-oficina@example.test','password'=>$f['password']]);
$reviewer=User::create(['name'=>'Verificador Sintético Oficina','email'=>'verificador-oficina@example.test','password'=>$f['password']]);
foreach([$tech,$reviewer] as $u) {
DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);
IdentityFixture::grant($u->id,$f['tenant'],$f['company'],'company.view'); IdentityFixture::grant($u->id,$f['tenant'],$f['company'],'unit.view',$f['unit']);
}
foreach(['view','record'] as $r) IdentityFixture::grant($tech->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
foreach(['view','verify'] as $r) IdentityFixture::grant($reviewer->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
foreach(['view','open','assign','authorize'] as $r) IdentityFixture::grant($f['manager']->id,$f['tenant'],$f['company'],'workshop.'.$r,$f['unit']);
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);
$client=app(ServiceWorkflow::class)->save('clients',null,$f['manager']->id,['name'=>'Cliente Sintético da Oficina'],(string)Str::uuid())['id'];
return $f+['technician'=>$tech,'reviewer'=>$reviewer,'client'=>$client];
}
}