140 lines
18 KiB
PHP
140 lines
18 KiB
PHP
<?php
|
|
namespace App\Modules\Industry;
|
|
use App\Modules\Identity\Services\Audit;
|
|
use App\Modules\Identity\Services\Permissions;
|
|
use App\Modules\Services\ServiceCalculation;
|
|
use App\Shared\Context\CompanyContext;
|
|
use App\Shared\Costs\ExactCostMath as Exact;
|
|
use App\Shared\Testing\TestingDatabaseGuard;
|
|
use Brick\Math\BigRational as Number;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
final class IndustrialCosts {
|
|
public const POLICY='INDUSTRIAL-COST-SIM-v1';
|
|
public const NOTICE='ENSAIO INDUSTRIAL — SEM UTILIZAÇÃO OPERACIONAL';
|
|
public const KINDS=['material','labour','pool','machine','production','material_rate','labour_rate','criterion'];
|
|
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
|
public static function guard(): void {abort_unless(ServiceCalculation::isolated(),403,'Custeio industrial real bloqueado; apenas testes isolados.');TestingDatabaseGuard::assertSafe();}
|
|
private function json(mixed $v): string {return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
|
|
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 exact(array $in,array $rules): array {abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do ensaio industrial.');return Validator::make($in,$rules)->validate();}
|
|
private function decimal(bool $positive=false): array {return ['bail','required','string','regex:/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{1,8})?$/D',function($a,$v,$fail)use($positive){if($positive&&Number::of($v)->isLessThanOrEqualTo(0))$fail('Quantidade/capacidade deve ser positiva.');}];}
|
|
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'],'industrial_costs.'.$r,$s['unit_id']);return $s;
|
|
}
|
|
private function lot(int $id,array $s): object {return DB::table('ic_lots')->where($s)->where('id',$id)->first()??abort(403,'Lote fora do âmbito autorizado.');}
|
|
private function command(?int $lot,int $actor,string $action,array $in,string $key,callable $write): array {
|
|
self::guard();$right=$action==='decide'?'validate':'prepare';$s=$this->scope($actor,$right);if($lot)$this->lot($lot,$s);
|
|
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência necessária.');$hash=hash('sha256',$this->json($this->canonical(['lot_id'=>$lot,'input'=>$in])));
|
|
return DB::transaction(function()use($lot,$actor,$action,$right,$s,$key,$hash,$write){
|
|
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$right);if($lot)$this->lot($lot,$s);
|
|
$old=DB::table('ic_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();
|
|
if($old){$this->lot($old->lot_id,$s);abort_unless(hash_equals($old->request_hash,$hash),409,'Repetição com conteúdo diferente.');return json_decode($old->result,true,64,JSON_THROW_ON_ERROR)+['replayed'=>true];}
|
|
$out=$write($s);$cmd=DB::table('ic_commands')->insertGetId($s+['lot_id'=>$out['lot_id'],'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);
|
|
Audit::record('industrial_costs.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
|
|
$event=DB::table('ic_events')->insertGetId(['command_id'=>$cmd,'schema_version'=>1,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'actor_id'=>$actor,'action'=>$action,'result'=>$out]),'created_at'=>now()]);
|
|
DB::table('ic_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $out+['replayed'=>false];
|
|
},3);
|
|
}
|
|
public function open(int $actor,array $in,string $key): array {
|
|
self::guard();$d=$this->exact($in,['reference'=>'required|string|min:3|max:100','product_reference'=>'required|string|min:3|max:100','product_name'=>'required|string|min:3|max:200','centre'=>'required|string|min:3|max:100','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','currency'=>'required|in:MZN']);
|
|
return $this->command(null,$actor,'open',$d,$key,function($s)use($actor,$d){
|
|
abort_if(DB::table('ic_lots')->where('company_id',$s['company_id'])->where('reference',$d['reference'])->exists(),409,'Identidade de lote já usada.');
|
|
abort_if(DB::table('ic_products')->where('company_id',$s['company_id'])->where('reference',$d['product_reference'])->exists(),409,'Este recorte cria um produto próprio por lote; identidade já usada.');
|
|
$product=DB::table('ic_products')->insertGetId($s+['reference'=>$d['product_reference'],'name'=>$d['product_name'],'version'=>1,'unit'=>'UN','actor_id'=>$actor,'created_at'=>now()]);
|
|
$id=DB::table('ic_lots')->insertGetId($s+['product_id'=>$product,'reference'=>$d['reference'],'centre'=>$d['centre'],'period_start'=>$d['period_start'],'period_end'=>$d['period_end'],'currency'=>$d['currency'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$id,'version'=>1,'kind'=>'lot'];
|
|
});
|
|
}
|
|
private function row(string $type,int $id,int $lot): object {return DB::table($type==='input'?'ic_inputs':'ic_results')->where(['id'=>$id,'lot_id'=>$lot])->first()??abort(403,'Origem/resultado fora do lote.');}
|
|
private function current(string $type,object $r): bool {return !DB::table($type==='input'?'ic_inputs':'ic_results')->where('previous_id',$r->id)->exists();}
|
|
private function approvedInput(int $id,int $version,int $lot,?string $kind=null): object {
|
|
$r=$this->row('input',$id,$lot);abort_unless($r->version===$version&&($kind===null||$r->kind===$kind),409,'Tipo/versão incompatível.');
|
|
abort_unless($this->current('input',$r)&&DB::table('ic_decisions')->where(['input_id'=>$id,'version'=>$version,'decision'=>'approved'])->exists(),409,'Origem/taxa/critério exige versão actual validada.');$r->data=json_decode($r->data,true,64,JSON_THROW_ON_ERROR);return $r;
|
|
}
|
|
private function validateData(string $kind,array $data,object $lot): array {
|
|
if(in_array($kind,['material','labour','pool','machine','production'])){
|
|
$unit=match($kind){'material'=>'KG','labour'=>'MIN','pool'=>'MZN','machine'=>'HM','production'=>'UN'};
|
|
$rules=['quantity'=>$this->decimal($kind!=='pool'),'unit'=>'required|in:'.$unit,'event_date'=>'required|date_format:Y-m-d'];if($kind==='production')$rules['completed']='required|accepted';$d=$this->exact($data,$rules);
|
|
abort_unless($d['event_date']>=$lot->period_start&&$d['event_date']<=$lot->period_end,422,'Origem fora do período.');return $d;
|
|
}
|
|
if(in_array($kind,['material_rate','labour_rate'])){
|
|
$d=$this->exact($data,['source_id'=>'required|integer|min:1','source_version'=>'required|integer|min:1','amount'=>$this->decimal(),'currency'=>'required|in:MZN','unit'=>'required|in:'.($kind==='material_rate'?'KG':'H')]);
|
|
$this->approvedInput($d['source_id'],$d['source_version'],$lot->id,str_replace('_rate','',$kind));return $d;
|
|
}
|
|
$d=$this->exact($data,['pool_id'=>'required|integer|min:1','pool_version'=>'required|integer|min:1','machine_id'=>'required|integer|min:1','machine_version'=>'required|integer|min:1','capacity'=>$this->decimal(true),'driver_unit'=>'required|in:HM','hypothesis'=>'required|in:synthetic_only']);
|
|
$this->approvedInput($d['pool_id'],$d['pool_version'],$lot->id,'pool');$this->approvedInput($d['machine_id'],$d['machine_version'],$lot->id,'machine');return $d;
|
|
}
|
|
public function input(int $lot,int $actor,array $in,string $key): array {
|
|
self::guard();$d=$this->exact($in,['kind'=>'required|in:'.implode(',',self::KINDS),'source_ref'=>['required','string','max:100','regex:/^IND-TEST-[A-Za-z0-9_-]+$/D'],'version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','data'=>'required|array','evidence'=>'required|string|min:3|max:1000']);
|
|
return $this->command($lot,$actor,'input',$d,$key,function($s)use($lot,$actor,$d){
|
|
$l=$this->lot($lot,$s);$data=$this->validateData($d['kind'],$d['data'],$l);$last=DB::table('ic_inputs')->where(['lot_id'=>$lot,'kind'=>$d['kind']])->orderByDesc('version')->first();
|
|
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Versão anterior exacta necessária.');
|
|
if($last)abort_unless($last->source_ref===$d['source_ref'],409,'Identidade da origem não pode mudar.');
|
|
else abort_if(DB::table('ic_inputs')->where(['company_id'=>$s['company_id'],'source_ref'=>$d['source_ref']])->exists(),409,'Origem canónica já usada; não duplicar entre lotes, unidades ou directo/CIF.');
|
|
$id=DB::table('ic_inputs')->insertGetId($s+['lot_id'=>$lot,'kind'=>$d['kind'],'source_ref'=>$d['source_ref'],'version'=>$d['version'],'previous_id'=>$last?->id,'data'=>$this->json($data),'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$d['version'],'kind'=>'input'];
|
|
});
|
|
}
|
|
private function compose(int $lot,array $refs): array {
|
|
abort_unless(array_diff(array_keys($refs),self::KINDS)===[]&&count($refs)===8,422,'Oito referências explícitas necessárias, incluindo taxa material ausente.');$rows=[];$versions=[];
|
|
foreach(self::KINDS as $kind){
|
|
$ref=$refs[$kind]??null;if($kind==='material_rate'&&$ref===null){$versions[$kind]=null;continue;}
|
|
abort_unless(is_array($ref),422,'Referência em falta: '.$kind);$r=$this->exact($ref,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']);$rows[$kind]=$this->approvedInput($r['id'],$r['version'],$lot,$kind);$versions[$kind]=$r;
|
|
}
|
|
$l=DB::table('ic_lots')->find($lot);
|
|
foreach($rows as $r)$this->validateData($r->kind,$r->data,$l);
|
|
foreach(['material','labour'] as $kind)if(isset($rows[$kind.'_rate'])){ $rate=$rows[$kind.'_rate']->data;abort_unless($rate['source_id']===$rows[$kind]->id&&$rate['source_version']===$rows[$kind]->version,409,'Taxa não corresponde à origem seleccionada.'); }
|
|
$c=$rows['criterion']->data;abort_unless($c['pool_id']===$rows['pool']->id&&$c['pool_version']===$rows['pool']->version&&$c['machine_id']===$rows['machine']->id&&$c['machine_version']===$rows['machine']->version,409,'Critério incompatível com as origens seleccionadas.');
|
|
$n=fn($k)=>Number::of($rows[$k]->data['quantity']);$pool=$n('pool');$machine=$n('machine');$capacity=Number::of($c['capacity']);
|
|
abort_if($machine->isGreaterThan($capacity),409,'Utilização superior à capacidade aprovada; residual negativo recusado.');
|
|
$tax=$pool->dividedBy($capacity);$allocated=$tax->multipliedBy($machine);$residual=$pool->minus($allocated);$hours=Exact::hours($rows['labour']->data['quantity']);$labour=$hours->multipliedBy($rows['labour_rate']->data['amount']);
|
|
$material=isset($rows['material_rate'])?$n('material')->multipliedBy($rows['material_rate']->data['amount']):null;
|
|
$known=$labour->plus($allocated)->plus($material??0);$origins=$labour->plus($pool)->plus($material??0);$pending=$material===null?['Taxa do material em falta; material excluído da reconciliação monetária parcial.']:[];
|
|
$originsDetail=[];foreach($rows as $kind=>$r)$originsDetail[$kind]=['id'=>$r->id,'version'=>$r->version,'source_ref'=>$r->source_ref,'data'=>$r->data,'evidence'=>$r->evidence];
|
|
return ['policy'=>self::POLICY,'notice'=>self::NOTICE,'hypothesis'=>'Hipótese sintética de capacidade/imputação; não é método contabilístico real aprovado.','currency'=>'MZN','refs'=>$versions,'origins'=>$originsDetail,
|
|
'material_exact'=>$material===null?null:Exact::text($material),'labour_hours_exact'=>Exact::text($hours),'labour_exact'=>Exact::text($labour),'pool_exact'=>Exact::text($pool),'capacity_hm'=>Exact::text($capacity),'used_hm'=>Exact::text($machine),'indirect_rate_exact'=>Exact::text($tax),'allocated_exact'=>Exact::text($allocated),'residual_exact'=>Exact::text($residual),'known_cost_exact'=>Exact::text($known),'selected_total_exact'=>$pending?null:Exact::text($known),'good_units'=>Exact::text($n('production')),'unit_cost_exact'=>$pending?null:Exact::text($known->dividedBy($n('production'))),'incomplete'=>$pending!==[],'pending'=>$pending,
|
|
'reconciliation'=>['coverage'=>$pending?'partial_known_only':'scenario_scope','known_origins_exact'=>Exact::text($origins),'assigned_exact'=>Exact::text($known),'unassigned_exact'=>Exact::text($residual),'balanced'=>$origins->isEqualTo($known->plus($residual))]];
|
|
}
|
|
private function resultCurrent(object $r): bool {
|
|
if(!$this->current('result',$r))return false;
|
|
try{$snapshot=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);return hash_equals($r->fingerprint,hash('sha256',$this->json($this->compose($r->lot_id,$snapshot['refs']))));}
|
|
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){return false;}
|
|
}
|
|
public function calculate(int $lot,int $actor,array $in,string $key): array {
|
|
self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','refs'=>'required|array','currency'=>'required|in:MZN']);
|
|
return $this->command($lot,$actor,'calculate',$d,$key,function($s)use($lot,$actor,$d){
|
|
$last=DB::table('ic_results')->where('lot_id',$lot)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Resultado mudou; rever versão anterior.');
|
|
$snapshot=$this->compose($lot,$d['refs']);$hash=hash('sha256',$this->json($snapshot));if($last&&hash_equals($last->fingerprint,$hash))return ['id'=>$last->id,'lot_id'=>$lot,'version'=>$last->version,'kind'=>'result','unchanged'=>true];
|
|
$id=DB::table('ic_results')->insertGetId($s+['lot_id'=>$lot,'version'=>$d['version'],'previous_id'=>$last?->id,'fingerprint'=>$hash,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$d['version'],'kind'=>'result'];
|
|
});
|
|
}
|
|
public function decide(int $lot,int $actor,array $in,string $key): array {
|
|
self::guard();$d=$this->exact($in,['type'=>'required|in:input,result','id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
|
|
return $this->command($lot,$actor,'decide',$d,$key,function($s)use($lot,$actor,$d){
|
|
$r=$this->row($d['type'],$d['id'],$lot);abort_if($r->actor_id===$actor,403,'Validador deve ser distinto do preparador.');abort_unless($r->version===$d['version']&&$this->current($d['type'],$r),409,'Versão obsoleta.');
|
|
if($d['type']==='input')$this->validateData($r->kind,json_decode($r->data,true),$this->lot($lot,$s));else abort_unless($this->resultCurrent($r),409,'Dependências alteradas; preparar nova versão.');
|
|
$field=$d['type']==='input'?'input_id':'result_id';abort_if(DB::table('ic_decisions')->where($field,$r->id)->exists(),409,'Versão já decidida.');
|
|
$id=DB::table('ic_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$r->version,'kind'=>'decision'];
|
|
});
|
|
}
|
|
/** Current owner conference for one lot; no listing universe or writer lock. */
|
|
public function conference(int $lot,int $actor):array {
|
|
self::guard();$s=$this->scope($actor,'view');$l=$this->lot($lot,$s);$r=DB::table('ic_results')->where($s)->where('lot_id',$lot)->orderByDesc('version')->first();$snapshot=$r?json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR):null;
|
|
$ids=array_column(array_filter($snapshot['refs']??[]),'id');$inputs=DB::table('ic_inputs')->where($s)->where('lot_id',$lot)->whereIn('id',$ids)->orderBy('id')->get();$decisions=DB::table('ic_decisions')->whereIn('input_id',$ids)->orderBy('id')->get();$decision=$r?DB::table('ic_decisions')->where('result_id',$r->id)->first():null;
|
|
return ['lot'=>(array)$l,'product'=>(array)DB::table('ic_products')->where($s)->where('id',$l->product_id)->first(),'result'=>$r?(array)$r:null,'snapshot'=>$snapshot,'decision'=>$decision?(array)$decision:null,'current'=>$r?$this->resultCurrent($r):false,'inputs'=>$inputs->map(fn($i)=>(array)$i)->all(),'input_decisions'=>$decisions->map(fn($i)=>(array)$i)->all()];
|
|
}
|
|
public function listing(int $actor): array {
|
|
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[],'notice'=>self::NOTICE];self::guard();
|
|
return DB::transaction(function()use($s){
|
|
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$lots=[];
|
|
foreach(DB::table('ic_lots')->where($s)->orderBy('id')->get() as $l){
|
|
$inputs=DB::table('ic_inputs')->where('lot_id',$l->id)->orderBy('id')->get();foreach($inputs as $i){$i->current=$this->current('input',$i);$i->data=json_decode($i->data,true);$i->decision=DB::table('ic_decisions')->where('input_id',$i->id)->first();}
|
|
$results=DB::table('ic_results')->where('lot_id',$l->id)->orderBy('version')->get();foreach($results as $r){$r->current=$this->resultCurrent($r);$r->snapshot=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$r->decision=DB::table('ic_decisions')->where('result_id',$r->id)->first();}
|
|
$lots[]=(array)$l+['product'=>DB::table('ic_products')->find($l->product_id),'inputs'=>$inputs->all(),'results'=>$results->all()];
|
|
}return ['blocked'=>false,'lots'=>$lots,'notice'=>self::NOTICE,'policy'=>self::POLICY];
|
|
});
|
|
}
|
|
}
|