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:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Services\Http;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Services\ServiceReceivables;
|
||||
use Illuminate\Http\Request;
|
||||
final class ServiceReceivablesController extends Controller
|
||||
{
|
||||
private function response(array $d) { return response()->json($d)->header('Cache-Control','no-store, private'); }
|
||||
public function index(Request $r,ServiceReceivables $s) { return $this->response($s->listing($r->user()->id)); }
|
||||
public function propose(Request $r,ServiceReceivables $s) { return $this->response($s->propose($r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
public function decide(Request $r,int $id,ServiceReceivables $s) { return $this->response($s->decide($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
public function allocate(Request $r,int $id,ServiceReceivables $s) { return $this->response($s->allocate($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/** Isolated operational subledger. Never invokes fiscal, banking or accounting consumers. */
|
||||
final class ServiceReceivables
|
||||
{
|
||||
public const POLICY='SGF-RECEIVABLES-SIM-v1';
|
||||
public const NOTICE='TESTE — SEM VALIDADE FISCAL';
|
||||
public const SIMULATION='SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO';
|
||||
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
|
||||
public static function guard(): void
|
||||
{
|
||||
abort_unless(ServiceCalculation::isolated(),403,'Recebimentos e reconciliação disponíveis exclusivamente em testes isolados.');
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
}
|
||||
private function scope(int $actor,string $right): array
|
||||
{
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
|
||||
abort_unless($actor===$this->context->userId() && $s['unit_id']!==null,403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach (['view',$right] as $r) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_receivables.'.$r,$s['unit_id']);
|
||||
return $s;
|
||||
}
|
||||
public static function money(mixed $value,bool $positive=true): string
|
||||
{
|
||||
$v=DraftValues::decimal($value,'amount');
|
||||
abort_unless($v!==null,422,'Montante explícito necessário.');
|
||||
$n=BigDecimal::of($v);
|
||||
abort_if(($positive && $n->isLessThanOrEqualTo(0)) || $n->getScale()>2,422,'Use um montante positivo exactamente representável a duas casas; sem truncagem.');
|
||||
return (string)$n->toScale(2,RoundingMode::UNNECESSARY);
|
||||
}
|
||||
private function validate(array $d,array $rules): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo protegido ou não suportado.');
|
||||
return Validator::make($d,$rules)->validate();
|
||||
}
|
||||
private function json(mixed $d): string { return json_encode($d,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); }
|
||||
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
|
||||
private function row(int $id,array $s,?string $kind=null): object
|
||||
{
|
||||
$q=DB::table('sr_records')->where($s)->where('id',$id);
|
||||
if($kind) $q->where('kind',$kind);
|
||||
return $q->first() ?? abort(403,'Identidade fora do âmbito ou tipo incorrecto.');
|
||||
}
|
||||
private function revision(int $ob): int { return DB::table('sr_effects')->where('obligation_id',$ob)->count(); }
|
||||
private function command(int $actor,string $right,string $key,array $input,callable $write): array
|
||||
{
|
||||
self::guard(); $s=$this->scope($actor,$right);
|
||||
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.');
|
||||
ksort($input); $hash=hash('sha256',$this->json($input));
|
||||
return DB::transaction(function() use($actor,$right,$key,$input,$hash,$s,$write) {
|
||||
// Same global order as the database guards: company, then immutable records/effects.
|
||||
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->scope($actor,$right);
|
||||
$old=DB::table('sr_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first();
|
||||
if($old) {
|
||||
abort_unless(hash_equals($old->request_hash,$hash),409,'Chave já usada com conteúdo diferente.');
|
||||
return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true];
|
||||
}
|
||||
$result=$write($s);
|
||||
$cmd=DB::table('sr_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
|
||||
Audit::record('service_receivables.'.$right,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['command_id'=>$cmd,'record_id'=>$result['id'],'simulation_only'=>true]);
|
||||
$event=DB::table('sr_events')->insertGetId(['command_id'=>$cmd,'type'=>$result['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'command_id'=>$cmd,'actor_id'=>$actor,'result'=>$result]),'created_at'=>now()]);
|
||||
DB::table('sr_outbox')->insert(['event_id'=>$event,'state'=>'captured_simulation_only']);
|
||||
return $result+['replayed'=>false];
|
||||
},3);
|
||||
}
|
||||
private function record(array $s,int $actor,string $kind,object $inv,int $client,?int $ob,?int $receipt,string $amount,string $source,string $external,array $data): array
|
||||
{
|
||||
abort_if(DB::table('sr_records')->where($s)->where(['kind'=>$kind,'source'=>$source,'external_id'=>$external])->exists(),409,'Acontecimento já registado; consulte a identidade original.');
|
||||
if($kind==='obligation') abort_if(DB::table('sr_records')->where('invoice_id',$inv->id)->where('kind','obligation')->exists(),409,'Documento já tem obrigação.');
|
||||
$origin=['invoice_id'=>$inv->id,'snapshot_sha256'=>hash('sha256',$inv->snapshot),'series'=>$inv->series_code,'number'=>$inv->number,'issued_at'=>$inv->issued_at];
|
||||
$id=DB::table('sr_records')->insertGetId($s+['invoice_id'=>$inv->id,'client_id'=>$client,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'currency'=>'MZN','amount'=>$amount,'source'=>$source,'external_id'=>$external,'data'=>$this->json(array_replace($data,['policy'=>self::POLICY,'origin'=>$origin])),'version'=>1,'actor_id'=>$actor,'created_at'=>now()]);
|
||||
return ['id'=>$id,'version'=>1,'event'=>$kind.'.proposed'];
|
||||
}
|
||||
public function propose(int $actor,array $input,string $key): array
|
||||
{
|
||||
self::guard();
|
||||
$d=$this->validate($input,['kind'=>'required|in:obligation,receipt,observation,correction','invoice_id'=>'required|integer|min:1','obligation_id'=>'nullable|integer|min:1','receipt_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','amount'=>'nullable|string','source'=>'required|string|min:3|max:80','external_id'=>'required|string|min:3|max:100','date'=>'nullable|date_format:Y-m-d','evidence'=>'nullable|string|min:3|max:1000','due_date'=>'nullable|date_format:Y-m-d','reason'=>'nullable|string|min:3|max:1000','action'=>'nullable|in:reverse_allocation,reverse_reconcile,reverse_receipt,reduction,due','target_effect_id'=>'nullable|integer|min:1','document_ref'=>'nullable|string|max:100','line'=>'nullable|in:1','revision'=>'required|integer|min:0']);
|
||||
$kind=$d['kind'];
|
||||
$right=$kind==='correction'?'correction_request':'prepare';
|
||||
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
|
||||
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
|
||||
$inv=DB::table('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
|
||||
abort_unless($inv,403,'Documento de origem não autorizado.');
|
||||
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
|
||||
$history=DB::table('a_history')->where($s)->where(['area'=>'billing_drafts','record_id'=>$inv->draft_id,'version'=>$snapshot['document']['draft_version']])->value('snapshot');
|
||||
abort_unless($history,409,'Versão histórica da origem necessária.');
|
||||
$client=(int)json_decode($history,true,32,JSON_THROW_ON_ERROR)['client_id'];
|
||||
$ob=null; $receipt=null; $amount='0.00';
|
||||
if($kind==='obligation') {
|
||||
abort_if(!empty($d['obligation_id']) || !empty($d['receipt_id']) || $d['revision']!==0,422);
|
||||
$amount=self::money($snapshot['calculation']['total_document_proposed']);
|
||||
abort_unless(!isset($d['amount']) || $d['amount']===$amount,422,'Montante deve coincidir com a fotografia.');
|
||||
} else {
|
||||
$ob=$this->row((int)($d['obligation_id']??0),$s,'obligation');
|
||||
abort_unless($ob->invoice_id===$inv->id,403);
|
||||
abort_unless($d['revision']===$this->revision($ob->id),409,'Revisão desactualizada.');
|
||||
abort_unless(DB::table('sr_effects')->where(['record_id'=>$ob->id,'kind'=>'open'])->exists(),409,'Obrigação ainda não aberta.');
|
||||
if(!empty($d['receipt_id'])) { $receipt=$this->row($d['receipt_id'],$s,'receipt'); abort_unless($receipt->obligation_id===$ob->id,403); }
|
||||
}
|
||||
if(in_array($kind,['receipt','observation'])) {
|
||||
abort_unless(isset($d['amount'],$d['date'],$d['evidence']),422,'Montante, data e evidência sintética são obrigatórios.');
|
||||
abort_if($d['date']>now(ServiceInvoicing::ZONE)->format('Y-m-d'),422,'Data futura não comprova recebimento ou observação.');
|
||||
$amount=$d['amount'];
|
||||
abort_if(($kind==='receipt' && $receipt) || ($kind==='observation' && !$receipt),422);
|
||||
}
|
||||
if($kind==='correction') {
|
||||
abort_unless(isset($d['action'],$d['reason']),422,'Acção e motivo necessários.');
|
||||
if($d['action']==='reduction') {
|
||||
abort_unless(isset($d['amount'],$d['document_ref'],$d['line']) && str_starts_with($d['document_ref'],'TEST-REDUCTION-'),422,'Documento redutor sintético e linha necessários.');
|
||||
$amount=$d['amount'];
|
||||
}
|
||||
if(in_array($d['action'],['reverse_allocation','reverse_reconcile'])) {
|
||||
$effect=DB::table('sr_effects')->where('obligation_id',$ob->id)->where('id',$d['target_effect_id']??0)->first();
|
||||
abort_unless($effect && $effect->kind===($d['action']==='reverse_allocation'?'allocate':'reconcile'),422,'Alvo de reversão incompatível.');
|
||||
$receipt=$this->row($effect->receipt_id,$s,'receipt'); $amount=$effect->amount;
|
||||
}
|
||||
if($d['action']==='reverse_receipt') { abort_unless($receipt,422); $amount=$receipt->amount; }
|
||||
}
|
||||
return $this->record($s,$actor,$kind,$inv,$client,$ob?->id,$receipt?->id,$amount,$d['source'],$d['external_id'],$d);
|
||||
});
|
||||
}
|
||||
public function decide(int $id,int $actor,array $input,string $key): array
|
||||
{
|
||||
self::guard(); $s=$this->scope($actor,'view'); $r=$this->row($id,$s);
|
||||
$right=$r->kind==='correction'?'correction_approve':($r->kind==='observation'?'reconcile':'confirm');
|
||||
$d=$this->validate($input,['version'=>'required|integer|in:1','revision'=>'required|integer|min:0','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
|
||||
return $this->command($actor,$right,$key,['record_id'=>$id]+$d,function($s)use($id,$actor,$d) {
|
||||
$r=$this->row($id,$s); $rd=$this->data($r); $ob=$r->kind==='obligation'?$r->id:$r->obligation_id;
|
||||
abort_if($r->actor_id===$actor,403,'Confirmação/aprovação exige identidade distinta.');
|
||||
abort_unless($d['revision']===$this->revision($ob),409,'Estado alterado; reveja a decisão.');
|
||||
abort_if(DB::table('sr_effects')->where('record_id',$id)->where('kind','<>','allocate')->exists(),409,'Versão já tem decisão.');
|
||||
$kind='refused'; $amount='0.00'; $receipt=null; $target=null;
|
||||
if($d['decision']==='approved') {
|
||||
if($r->kind==='obligation') { $kind='open'; $amount=$r->amount; }
|
||||
if($r->kind==='receipt') { $kind='receipt_confirm'; $amount=$r->amount; }
|
||||
if($r->kind==='observation') {
|
||||
$receipt=$r->receipt_id; $rc=$this->row($receipt,$s,'receipt');
|
||||
$diff=BigDecimal::of($rc->amount)->minus($r->amount)->abs();
|
||||
$kind=$diff->isZero()?'reconcile':'divergence'; $amount=$diff->isZero()?$rc->amount:(string)$diff->toScale(2);
|
||||
}
|
||||
if($r->kind==='correction') {
|
||||
abort_unless(($rd['revision']??-1)===$d['revision'],409,'Proposta ligada a revisão anterior; prepare nova proposta.');
|
||||
$kind=$rd['action']; $amount=$r->amount; $receipt=$r->receipt_id; $target=$rd['target_effect_id']??null;
|
||||
if($kind==='reduction' || $kind==='due') $receipt=null;
|
||||
}
|
||||
} elseif($r->kind==='observation') abort(422,'A conferência regista correspondência ou divergência; não é aprovação de recibo.');
|
||||
return $this->effect($id,$ob,$receipt,$kind,$amount,$target,$d['revision'],$actor,$d+['due_date'=>$rd['due_date']??null]);
|
||||
});
|
||||
}
|
||||
private function effect(int $record,int $ob,?int $receipt,string $kind,string $amount,?int $target,int $revision,int $actor,array $data): array
|
||||
{
|
||||
$state=$this->state($ob);
|
||||
if($kind==='allocate') {
|
||||
$r=$state['receipts'][$receipt]??abort(409,'Recebimento indisponível.');
|
||||
abort_unless($r['state']==='confirmed' && BigDecimal::of($amount)->isLessThanOrEqualTo($r['available']) && BigDecimal::of($amount)->isLessThanOrEqualTo($state['balance']),409,'Sobreafectação recusada integralmente.');
|
||||
}
|
||||
if($kind==='reduction') abort_if(BigDecimal::of($amount)->isGreaterThan($state['balance']),409,'Liberte afectação antes da redução.');
|
||||
if($kind==='reverse_receipt') {
|
||||
$r=$state['receipts'][$receipt]??abort(409);
|
||||
abort_unless($r['state']==='confirmed' && $r['allocated']==='0.00' && !$r['reconciled'],409,'Reverta afectações e reconciliação primeiro.');
|
||||
}
|
||||
if(in_array($kind,['reconcile','divergence'])) {
|
||||
$r=$state['receipts'][$receipt]??abort(409);
|
||||
abort_unless($r['state']==='confirmed' && !$r['reconciled'],409,'Recebimento indisponível ou já conferido.');
|
||||
}
|
||||
if($target) abort_if(DB::table('sr_effects')->where('target_effect_id',$target)->exists(),409,'Efeito já revertido.');
|
||||
$id=DB::table('sr_effects')->insertGetId(['record_id'=>$record,'obligation_id'=>$ob,'receipt_id'=>$receipt,'kind'=>$kind,'amount'=>$amount,'target_effect_id'=>$target,'revision'=>$revision,'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]);
|
||||
return ['id'=>$id,'record_id'=>$record,'obligation_id'=>$ob,'event'=>$kind.'.simulation','revision'=>$revision+1];
|
||||
}
|
||||
public function allocate(int $id,int $actor,array $input,string $key): array
|
||||
{
|
||||
self::guard(); $d=$this->validate($input,['amount'=>'required|string','revision'=>'required|integer|min:0','version'=>'required|integer|in:1']);
|
||||
$d['amount']=self::money($d['amount']);
|
||||
return $this->command($actor,'allocate',$key,['receipt_id'=>$id]+$d,function($s)use($id,$actor,$d) {
|
||||
$r=$this->row($id,$s,'receipt'); abort_unless($d['revision']===$this->revision($r->obligation_id),409,'Revisão desactualizada.');
|
||||
return $this->effect($id,$r->obligation_id,$id,'allocate',$d['amount'],null,$d['revision'],$actor,[]);
|
||||
});
|
||||
}
|
||||
private function state(int $ob,?string $cut=null): array
|
||||
{
|
||||
$r=DB::table('sr_records')->find($ob); $rd=$this->data($r);
|
||||
$effects=DB::table('sr_effects')->where('obligation_id',$ob)->orderBy('id')->get();
|
||||
$receipts=[]; $balance=BigDecimal::of($r->amount); $reduced=BigDecimal::zero(); $allocated=BigDecimal::zero(); $due=$rd['due_date']??null; $opened=false; $refused=false;
|
||||
foreach(DB::table('sr_records')->where('obligation_id',$ob)->where('kind','receipt')->get() as $rc) $receipts[$rc->id]=['id'=>$rc->id,'amount'=>$rc->amount,'state'=>'proposed','allocated'=>'0.00','available'=>'0.00','reconciled'=>false];
|
||||
foreach($effects as $e) {
|
||||
if($e->kind==='open') $opened=true;
|
||||
if($e->kind==='refused' && $e->record_id===$ob) $refused=true;
|
||||
if($e->kind==='due') $due=$this->data($e)['due_date'];
|
||||
if($e->kind==='receipt_confirm') { $receipts[$e->record_id]['state']='confirmed'; $receipts[$e->record_id]['available']=$e->amount; }
|
||||
if($e->kind==='refused' && isset($receipts[$e->record_id])) $receipts[$e->record_id]['state']='refused';
|
||||
if(in_array($e->kind,['allocate','reverse_allocation'])) {
|
||||
$x=BigDecimal::of($e->amount)->multipliedBy($e->kind==='allocate'?1:-1); $allocated=$allocated->plus($x);
|
||||
$rc=&$receipts[$e->receipt_id]; $rc['allocated']=(string)BigDecimal::of($rc['allocated'])->plus($x)->toScale(2); $rc['available']=(string)BigDecimal::of($rc['available'])->minus($x)->toScale(2); unset($rc);
|
||||
}
|
||||
if($e->kind==='reduction') $reduced=$reduced->plus($e->amount);
|
||||
if($e->kind==='reconcile') $receipts[$e->receipt_id]['reconciled']=true;
|
||||
if($e->kind==='reverse_reconcile') $receipts[$e->receipt_id]['reconciled']=false;
|
||||
if($e->kind==='reverse_receipt') { $receipts[$e->receipt_id]['state']='reversed'; $receipts[$e->receipt_id]['available']='0.00'; }
|
||||
}
|
||||
$balance=$balance->minus($reduced)->minus($allocated); $received=BigDecimal::zero(); $available=BigDecimal::zero();
|
||||
foreach($receipts as $rc) if($rc['state']==='confirmed') { $received=$received->plus($rc['amount']); $available=$available->plus($rc['available']); }
|
||||
$cut??=now(ServiceInvoicing::ZONE)->format('Y-m-d');
|
||||
return ['id'=>$ob,'original'=>$r->amount,'reduced'=>(string)$reduced->toScale(2),'received'=>(string)$received->toScale(2),'allocated'=>(string)$allocated->toScale(2),'available'=>(string)$available->toScale(2),'balance'=>(string)$balance->toScale(2),'opened'=>$opened,'state'=>!$opened?($refused?'refused':'proposed'):($balance->isZero()?'settled':($allocated->isPositive()?'partial':'open')),'due_date'=>$due,'due_state'=>!$opened?'not_open':($balance->isZero()?'settled':($due===null?'unknown':($cut>$due?'overdue':'not_due'))),'cut_date'=>$cut,'revision'=>$effects->count(),'receipts'=>$receipts,'effects'=>$effects->all()];
|
||||
}
|
||||
public function listing(int $actor): array
|
||||
{
|
||||
$s=$this->scope($actor,'view');
|
||||
if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'obligations'=>[],'records'=>[],'documents'=>[]];
|
||||
self::guard();
|
||||
return DB::transaction(function()use($s) {
|
||||
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
|
||||
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>DB::table('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
|
||||
});
|
||||
}
|
||||
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
|
||||
public function deliver(int $event): bool
|
||||
{
|
||||
self::guard();
|
||||
return DB::transaction(function()use($event) {
|
||||
$e=DB::table('sr_events')->where('id',$event)->lockForUpdate()->firstOrFail();
|
||||
if(DB::table('sr_deliveries')->where('event_id',$event)->exists()) return false;
|
||||
$cmd=DB::table('sr_commands')->find($e->command_id); $result=json_decode($cmd->result,true,32,JSON_THROW_ON_ERROR);
|
||||
$ob=$result['obligation_id']??null;
|
||||
if($ob) { $state=$this->state($ob); abort_if(BigDecimal::of($state['balance'])->isNegative(),409,'Integridade do saldo falhou.'); }
|
||||
DB::table('sr_deliveries')->insert(['event_id'=>$event,'consumer'=>'simulation_integrity_v1','created_at'=>now()]);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Workshop\WorkshopWorkflow;
|
||||
use Illuminate\Http\Request;
|
||||
final class WorkshopController extends Controller
|
||||
{
|
||||
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
|
||||
public function index(Request $r,WorkshopWorkflow $w) { return $this->response($w->listing($r->user()->id)); }
|
||||
public function open(Request $r,WorkshopWorkflow $w) { return $this->response($w->open($r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
public function record(Request $r,int $id,WorkshopWorkflow $w) { return $this->response($w->record($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
public function verify(Request $r,int $id,WorkshopWorkflow $w) { return $this->response($w->verify($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Services\DraftValues;
|
||||
use App\Modules\Services\ServiceCalculation;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
final class WorkshopWorkflow
|
||||
{
|
||||
public const POLICY='WORKSHOP-RESOURCE-RECORD-v1';
|
||||
public const NOTICE='TESTE — SEM UTILIZAÇÃO OPERACIONAL';
|
||||
public const ZONE='Africa/Johannesburg';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions) {}
|
||||
public static function guard(): void {
|
||||
abort_unless(ServiceCalculation::isolated(),403,'Oficina disponível apenas na instância isolada de testes.');
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
}
|
||||
private function scope(int $actor,string $right): array {
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];
|
||||
abort_unless($s['unit_id']!==null && $actor===$this->context->userId(),403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(['view',$right] as $r) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$r,$s['unit_id']);
|
||||
return $s;
|
||||
}
|
||||
private function has(int $actor,string $right,array $s): bool { return $this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'workshop.'.$right,$s['unit_id']); }
|
||||
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
|
||||
private function json(mixed $value): string { return json_encode($value,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE); }
|
||||
private function exact(array $data,array $rules): array {
|
||||
abort_if(array_diff(array_keys($data),array_keys($rules)),422,'Campo protegido ou fora do recorte.');
|
||||
return Validator::make($data,$rules)->validate();
|
||||
}
|
||||
private function canonical(mixed $v): mixed {
|
||||
if(!is_array($v)) return $v;
|
||||
if(!array_is_list($v)) ksort($v);
|
||||
return array_map(fn($x)=>$this->canonical($x),$v);
|
||||
}
|
||||
private function revision(int $id): int { return (int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$id])->v; }
|
||||
private function order(int $id,int $actor,array $s): object {
|
||||
$o=DB::table('wo_orders')->where($s)->where('id',$id)->first();
|
||||
abort_unless($o,403,'Ordem fora do âmbito autorizado.');
|
||||
if(!$this->has($actor,'open',$s) && !$this->has($actor,'assign',$s) && !$this->has($actor,'authorize',$s) && !$this->has($actor,'verify',$s)) {
|
||||
abort_unless((int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id')===$actor,403,'Ordem não atribuída ao técnico.');
|
||||
}
|
||||
return $o;
|
||||
}
|
||||
private function entry(int $id,int $order,?string $kind=null): object {
|
||||
$q=DB::table('wo_entries')->where(['order_id'=>$order,'id'=>$id]); if($kind) $q->where('kind',$kind);
|
||||
return $q->first()??abort(403,'Apontamento/origem fora da ordem.');
|
||||
}
|
||||
private function current(object $e): bool { return !DB::table('wo_entries')->where('previous_id',$e->id)->exists(); }
|
||||
private function command(int $actor,string $right,string $key,array $input,?int $order,callable $write): array {
|
||||
self::guard(); $s=$this->scope($actor,$right);
|
||||
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência obrigatória.');
|
||||
$hash=hash('sha256',$this->json($this->canonical($input)));
|
||||
return DB::transaction(function()use($actor,$right,$key,$hash,$s,$write,$order) {
|
||||
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail(); $this->scope($actor,$right);
|
||||
if($order) $this->order($order,$actor,$s);
|
||||
$old=DB::table('wo_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$right,'command_key'=>$key])->first();
|
||||
if($old) {
|
||||
$this->order($old->order_id,$actor,$s);
|
||||
abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com conteúdo diferente.');
|
||||
return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true];
|
||||
}
|
||||
$out=$write($s);
|
||||
$cmd=DB::table('wo_commands')->insertGetId($s+['actor_id'=>$actor,'action'=>$right,'command_key'=>$key,'request_hash'=>$hash,'order_id'=>$out['order_id'],'result'=>$this->json($out),'created_at'=>now()]);
|
||||
Audit::record('workshop.'.$out['event'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']??1]);
|
||||
$event=DB::table('wo_events')->insertGetId(['command_id'=>$cmd,'type'=>$out['event'],'schema_version'=>1,'payload'=>$this->json($s+['policy'=>self::POLICY,'actor_id'=>$actor,'command_id'=>$cmd,'result'=>$out]),'created_at'=>now()]);
|
||||
DB::table('wo_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_cost_stock_or_billing']);
|
||||
return $out+['replayed'=>false];
|
||||
},3);
|
||||
}
|
||||
public function open(int $actor,array $input,string $key): array {
|
||||
self::guard();
|
||||
$d=$this->exact($input,['reference'=>'required|string|min:3|max:100','client_id'=>'required|integer|min:1','equipment_id'=>'nullable|integer|min:1','equipment_identifier'=>'required_without:equipment_id|string|max:100','equipment_description'=>'required_without:equipment_id|string|max:300','serial'=>'nullable|string|max:100','request'=>'required|string|min:3|max:2000','location'=>'required|string|max:300','evidence'=>'required|string|min:3|max:500']);
|
||||
return $this->command($actor,'open',$key,$d,null,function($s)use($d,$actor) {
|
||||
abort_unless(DB::table('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
|
||||
abort_if(DB::table('wo_orders')->where(['company_id'=>$s['company_id'],'reference'=>$d['reference']])->exists(),409,'Referência de ordem já existente.');
|
||||
$equipment=$d['equipment_id']??null;
|
||||
if($equipment) abort_unless(DB::table('wo_equipment')->where(['id'=>$equipment,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id']])->exists(),403,'Equipamento incompatível com o cliente.');
|
||||
else {
|
||||
abort_if(DB::table('wo_equipment')->where(['company_id'=>$s['company_id'],'identifier'=>$d['equipment_identifier']])->exists(),409,'Equipamento já identificado; reutilize o ID existente.');
|
||||
$equipment=DB::table('wo_equipment')->insertGetId(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id'],'identifier'=>$d['equipment_identifier'],'description'=>$d['equipment_description'],'serial'=>$d['serial']??null,'actor_id'=>$actor,'created_at'=>now()]);
|
||||
}
|
||||
$id=DB::table('wo_orders')->insertGetId($s+['client_id'=>$d['client_id'],'equipment_id'=>$equipment,'reference'=>$d['reference'],'request'=>$d['request'],'location'=>$d['location'],'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]);
|
||||
return ['id'=>$id,'order_id'=>$id,'equipment_id'=>$equipment,'event'=>'order.opened','version'=>1];
|
||||
});
|
||||
}
|
||||
private function timestamp(string $value): string {
|
||||
abort_unless(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(Z|[+-]\d{2}:\d{2})$/D',$value),422,'Use data/hora completa com zona explícita.');
|
||||
try { return CarbonImmutable::parse($value)->utc()->format('Y-m-d\TH:i:sP'); }
|
||||
catch(\Throwable) { abort(422,'Data/hora inválida.'); }
|
||||
}
|
||||
public function record(int $id,int $actor,array $input,string $key): array {
|
||||
self::guard();
|
||||
$d=$this->exact($input,['kind'=>'required|in:assignment,task,authorization,start,time,material','origin'=>'required|string|min:3|max:100','revision'=>'required|integer|min:0','previous_id'=>'nullable|integer|min:1','correction_reason'=>'nullable|string|min:3|max:1000','version'=>'required|integer|min:1',
|
||||
'technician_id'=>'nullable|integer|min:1','task_id'=>'nullable|integer|min:1','authorization_id'=>'nullable|integer|min:1','start_id'=>'nullable|integer|min:1',
|
||||
'description'=>'nullable|string|min:3|max:2000','service_id'=>'nullable|integer|min:1','evidence'=>'nullable|string|min:3|max:1000',
|
||||
'authorized_by'=>'nullable|string|min:3|max:255','capacity'=>'nullable|string|min:3|max:255','authorized_at'=>'nullable|date','channel'=>'nullable|string|min:3|max:100','scope'=>'nullable|string|min:3|max:2000',
|
||||
'starts_at'=>'nullable|date','ends_at'=>'nullable|date','pauses'=>'nullable|array|max:100','pauses.*'=>'array:start,end','pauses.*.start'=>'required|date','pauses.*.end'=>'required|date','mode'=>'nullable|in:reported,started','time_type'=>'nullable|in:work,travel',
|
||||
'quantity'=>'nullable|string','resource'=>'nullable|string|min:2|max:100','unit'=>'nullable|in:UN,KG,M,L','owner'=>'nullable|in:company,client','event'=>'nullable|in:required,delivered,consumed,returned']);
|
||||
foreach(['starts_at','ends_at','authorized_at'] as $field) if(isset($d[$field])) $d[$field]=$this->timestamp($d[$field]);
|
||||
if(isset($d['quantity'])) $d['quantity']=DraftValues::decimal($d['quantity'],'quantity',true);
|
||||
$d['pauses']=array_map(fn($p)=>['start'=>$this->timestamp($p['start']),'end'=>$this->timestamp($p['end'])],$d['pauses']??[]);
|
||||
$right=match($d['kind']) {'assignment'=>'assign','authorization'=>'authorize',default=>'record'};
|
||||
return $this->command($actor,$right,$key,['order_id'=>$id]+$d,$id,function($s)use($id,$actor,$d) {
|
||||
abort_unless((int)$d['revision']===$this->revision($id),409,'Revisão da ordem desactualizada.');
|
||||
$kind=$d['kind']; $prev=null;
|
||||
if(!empty($d['previous_id'])) {
|
||||
$prev=$this->entry($d['previous_id'],$id,$kind);
|
||||
abort_unless($this->current($prev) && $prev->origin===$d['origin'] && (int)$d['version']===$prev->version+1 && !empty($d['correction_reason']),409,'Nova versão exige origem, motivo e versão anterior exactos.');
|
||||
if(in_array($kind,['time','material','task'])) abort_unless($prev->actor_id===$actor,403,'Não pode corrigir o apontamento de outro autor.');
|
||||
} else {
|
||||
abort_unless((int)$d['version']===1,422);
|
||||
abort_if(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin']])->exists(),409,'Origem já registada; corrija por versão explícita.');
|
||||
}
|
||||
$assigned=(int)DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'assignment'])->orderByDesc('id')->value('technician_id');
|
||||
$tech=null; $task=null; $auth=null; $start=null; $minutes=null; $quantity=null; $outside=false; $starts=$d['starts_at']??null; $ends=$d['ends_at']??null;
|
||||
if($kind==='assignment') {
|
||||
$tech=$d['technician_id']??0;
|
||||
foreach(['view','record'] as $r) $this->permissions->require($tech,$s['tenant_id'],$s['company_id'],'workshop.'.$r,$s['unit_id']);
|
||||
}
|
||||
if(in_array($kind,['task','start','time','material'])) { abort_unless($assigned===$actor,403,'Apenas o técnico atribuído regista.'); $tech=$actor; }
|
||||
if($kind==='task') abort_unless(!empty($d['description']),422,'Descrição da tarefa necessária.');
|
||||
if(in_array($kind,['authorization','start','time','material'])) {
|
||||
$task=$this->entry((int)($d['task_id']??0),$id,'task');
|
||||
if(in_array($kind,['authorization','start'])) abort_unless($this->current($task),409,'Tarefa tem nova versão; reveja âmbito.');
|
||||
}
|
||||
if($kind==='authorization') {
|
||||
foreach(['authorized_by','capacity','authorized_at','channel','scope','evidence'] as $field) abort_unless(!empty($d[$field]),422,'Autorização: pessoa, qualidade, data, canal, âmbito e comprovativo necessários.');
|
||||
abort_if(CarbonImmutable::parse($d['authorized_at'])->isAfter(now()),422,'Autorização futura não é facto.');
|
||||
// Revisions of one authorization cannot silently move to another task or authorize it retroactively.
|
||||
if($prev) abort_unless($prev->task_id===$task->id || !$this->current($this->entry($prev->task_id,$id,'task')),409,'Alteração de tarefa exige revisão explícita da origem.');
|
||||
}
|
||||
if(in_array($kind,['start','time','material'])) {
|
||||
abort_unless(!empty($d['evidence']),422,'Evidência da ocorrência necessária.');
|
||||
if(!empty($d['authorization_id'])) $auth=$this->entry($d['authorization_id'],$id,'authorization');
|
||||
if($auth) abort_unless($auth->task_id===$task->id,409,'Autorização não cobre esta versão da tarefa.');
|
||||
if($kind==='start') { $starts=now()->toIso8601String(); $ends=null; }
|
||||
$outside=!$auth || !$this->current($auth) || !$this->current($task) || CarbonImmutable::parse($auth->created_at)->isAfter(CarbonImmutable::parse($starts??now())) || CarbonImmutable::parse($this->data($auth)['authorized_at'])->isAfter(CarbonImmutable::parse($starts??now()));
|
||||
if($kind==='start') {
|
||||
abort_if($outside,409,'Início bloqueado: autorização da versão da tarefa necessária. Pode relatar uma ocorrência já acontecida para revisão.');
|
||||
abort_if(DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'start','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as done')->whereColumn('done.start_id','e.id'))->exists(),409,'Já existe trabalho iniciado sem apontamento final.');
|
||||
}
|
||||
if($kind==='time') {
|
||||
abort_unless($starts && $ends && !empty($d['time_type']) && !empty($d['mode']),422,'Intervalo, tipo e modo de relato necessários.');
|
||||
$from=CarbonImmutable::parse($starts); $to=CarbonImmutable::parse($ends);
|
||||
abort_unless($to->isAfter($from) && !$to->isAfter(now()),422,'Intervalo inválido ou ocorrência futura.');
|
||||
$seconds=$to->getTimestamp()-$from->getTimestamp(); $last=$from;
|
||||
foreach($d['pauses'] as $pause) {
|
||||
$a=CarbonImmutable::parse($pause['start']); $b=CarbonImmutable::parse($pause['end']);
|
||||
abort_unless(!$a->isBefore($last) && $b->isAfter($a) && !$b->isAfter($to),422,'Pausas inválidas/sobrepostas.');
|
||||
$seconds-=$b->getTimestamp()-$a->getTimestamp(); $last=$b;
|
||||
}
|
||||
abort_unless($seconds>0 && $seconds%60===0,422,'Tempo efectivo deve ser exactamente representável em minutos; sem truncagem.'); $minutes=intdiv($seconds,60);
|
||||
if(!empty($d['start_id'])) {
|
||||
$start=$this->entry($d['start_id'],$id,'start');
|
||||
abort_unless($start->technician_id===$actor && $start->task_id===$task->id && $start->authorization_id===($auth?->id) && CarbonImmutable::parse($start->starts_at)->eq($from),409,'Início incompatível.');
|
||||
abort_if(DB::table('wo_entries')->where('start_id',$start->id)->exists(),409,'Início já concluído.');
|
||||
} else abort_unless($d['mode']==='reported',422,'Seleccione relato de ocorrência ou indique início existente.');
|
||||
// A correction cannot silently turn an already reported exception into authorized work.
|
||||
if($prev && $prev->outside_authority) $outside=true;
|
||||
$overlap=DB::table('wo_entries as e')->join('wo_orders as o','o.id','=','e.order_id')->where('o.company_id',$s['company_id'])->where(['e.kind'=>'time','e.technician_id'=>$actor])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as n')->whereColumn('n.previous_id','e.id'))->when($prev,fn($q)=>$q->where('e.id','<>',$prev->id))->where('e.starts_at','<',$ends)->where('e.ends_at','>',$starts)->exists();
|
||||
abort_if($overlap,409,'Intervalo sobreposto do mesmo técnico; não será duplicado.');
|
||||
}
|
||||
if($kind==='material') {
|
||||
foreach(['quantity','resource','unit','owner','event','starts_at'] as $f) abort_unless(isset($d[$f]),422,'Material: recurso, unidade, propriedade, tipo, quantidade e data necessários.');
|
||||
abort_if(CarbonImmutable::parse($starts)->isAfter(now()),422,'Ocorrência futura não é material observado.');
|
||||
$quantity=$d['quantity'];
|
||||
foreach(DB::table('wo_entries')->where(['order_id'=>$id,'kind'=>'material'])->get() as $other) {
|
||||
$od=$this->data($other);
|
||||
if($od['resource']===$d['resource']) abort_unless($od['unit']===$d['unit'] && $od['owner']===$d['owner'],409,'A referência material conserva unidade e propriedade.');
|
||||
}
|
||||
if($prev) { $pd=$this->data($prev); foreach(['resource','unit','owner','event'] as $field) abort_unless($pd[$field]===$d[$field],409,'A correcção não pode transferir a origem material para outro acontecimento.'); }
|
||||
if($d['event']!=='consumed') $outside=false;
|
||||
if($prev && $prev->outside_authority) $outside=true;
|
||||
}
|
||||
}
|
||||
$data=$d+['policy'=>self::POLICY];
|
||||
$entry=DB::table('wo_entries')->insertGetId(['order_id'=>$id,'kind'=>$kind,'origin'=>$d['origin'],'version'=>$d['version'],'previous_id'=>$prev?->id,'task_id'=>$task?->id,'authorization_id'=>$auth?->id,'start_id'=>$start?->id,'technician_id'=>$tech,'starts_at'=>$starts,'ends_at'=>$ends,'minutes'=>$minutes,'quantity'=>$quantity,'outside_authority'=>$outside,'revision'=>$d['revision'],'data'=>$this->json($data),'actor_id'=>$actor,'created_at'=>now()]);
|
||||
$actual=DB::table('wo_entries')->find($entry);
|
||||
return ['id'=>$entry,'order_id'=>$id,'version'=>(int)$d['version'],'event'=>$kind.'.recorded','outside_authority'=>(bool)$actual->outside_authority,'revision'=>$this->revision($id)];
|
||||
});
|
||||
}
|
||||
public function verify(int $id,int $actor,array $input,string $key): array {
|
||||
self::guard(); $s=$this->scope($actor,'verify'); $entry=DB::table('wo_entries')->find($id); abort_unless($entry,403); $this->order($entry->order_id,$actor,$s);
|
||||
$d=$this->exact($input,['version'=>'required|integer|min:1','revision'=>'required|integer|min:0','decision'=>'required|in:verified,refused,acknowledged','reason'=>'required|string|min:3|max:1000']);
|
||||
return $this->command($actor,'verify',$key,['entry_id'=>$id]+$d,$entry->order_id,function($s)use($entry,$actor,$d) {
|
||||
$e=$this->entry($entry->id,$entry->order_id);
|
||||
abort_unless(in_array($e->kind,['task','time','material']),422,'Este registo não é apontamento verificável.');
|
||||
abort_if($e->actor_id===$actor,403,'Verificador deve ser distinto do autor.');
|
||||
abort_unless($this->current($e) && (int)$d['version']===$e->version && (int)$d['revision']===$this->revision($e->order_id),409,'Versão/revisão desactualizada.');
|
||||
abort_if(DB::table('wo_decisions')->where('entry_id',$e->id)->exists(),409,'Versão já decidida.');
|
||||
abort_if($e->outside_authority && $d['decision']==='verified',409,'Fora do âmbito: pode reconhecer a ocorrência para auditoria ou recusar, sem autorizar retroactivamente.');
|
||||
abort_if(!$e->outside_authority && $d['decision']==='acknowledged',422,'Reconhecimento de excepção apenas para ocorrência fora do âmbito.');
|
||||
$decision=DB::table('wo_decisions')->insertGetId(['entry_id'=>$e->id,'entry_version'=>$d['version'],'revision'=>$d['revision'],'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);
|
||||
return ['id'=>$decision,'entry_id'=>$e->id,'order_id'=>$e->order_id,'version'=>$e->version,'event'=>'entry.'.$d['decision'],'revision'=>$this->revision($e->order_id)];
|
||||
});
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$s=$this->scope($actor,'view');
|
||||
if(!ServiceCalculation::isolated()) return ['blocked'=>true,'notice'=>self::NOTICE,'orders'=>[]];
|
||||
self::guard();
|
||||
return DB::transaction(function()use($s,$actor) {
|
||||
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$orders=[];
|
||||
foreach(DB::table('wo_orders')->where($s)->orderBy('id')->get() as $o) {
|
||||
try {$this->order($o->id,$actor,$s);} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {continue;}
|
||||
$entries=DB::table('wo_entries')->where('order_id',$o->id)->orderBy('id')->get();
|
||||
foreach($entries as $e) {
|
||||
$e->data=$this->data($e); $e->current=$this->current($e);
|
||||
$e->decision=DB::table('wo_decisions')->where('entry_id',$e->id)->first();
|
||||
}
|
||||
$current=$entries->where('current',true); $points=$current->whereIn('kind',['task','time','material']);
|
||||
$exception=$points->contains(fn($e)=>$e->outside_authority);
|
||||
$pending=$points->contains(fn($e)=>!$e->decision || $e->decision->decision!=='verified');
|
||||
$state=$points->isEmpty()?'open':($exception?'exception_review':($pending?'in_review':'records_verified'));
|
||||
$materials=[];
|
||||
foreach($current->where('kind','material') as $e) {
|
||||
$k=$e->data['resource'].'|'.$e->data['owner'].'|'.$e->data['unit'];
|
||||
$materials[$k]??=['resource'=>$e->data['resource'],'unit'=>$e->data['unit'],'owner'=>$e->data['owner'],'required'=>'0','delivered'=>'0','consumed'=>'0','returned'=>'0','verified_consumed'=>'0'];
|
||||
$event=$e->data['event']; $materials[$k][$event]=(string)\Brick\Math\BigDecimal::of($materials[$k][$event])->plus($e->quantity);
|
||||
if($event==='consumed' && $e->decision?->decision==='verified') $materials[$k]['verified_consumed']=(string)\Brick\Math\BigDecimal::of($materials[$k]['verified_consumed'])->plus($e->quantity);
|
||||
}
|
||||
foreach($materials as &$m) { $m['unexplained']=(string)\Brick\Math\BigDecimal::of($m['delivered'])->minus($m['consumed'])->minus($m['returned']); $m['status']=$m['unexplained']==='0'?'balanced_observations':'pending_physical_explanation'; } unset($m);
|
||||
$orders[]=(array)$o+['equipment'=>DB::table('wo_equipment')->find($o->equipment_id),'revision'=>$this->revision($o->id),'assigned_to'=>$entries->where('kind','assignment')->last()?->technician_id,'state'=>$state,'entries'=>$entries->all(),'minutes_reported'=>$current->where('kind','time')->sum('minutes'),'minutes_verified'=>$current->where('kind','time')->filter(fn($e)=>$e->decision?->decision==='verified')->sum('minutes'),'materials'=>array_values($materials)];
|
||||
}
|
||||
$techs=$this->has($actor,'assign',$s)?DB::table('permission_grants as g')->join('users as u','u.id','=','g.user_id')->where(['g.company_id'=>$s['company_id'],'g.unit_id'=>$s['unit_id'],'g.permission'=>'workshop.record'])->get(['u.id','u.name'])->all():[];
|
||||
$clients=DB::table('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
|
||||
if(!$this->has($actor,'open',$s)) $clients->whereIn('id',array_column($orders,'client_id'));
|
||||
return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs];
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user