247 lines
31 KiB
PHP
247 lines
31 KiB
PHP
<?php
|
|
namespace App\Modules\Workshop;
|
|
|
|
use App\Modules\Identity\Services\{Audit,Permissions};
|
|
use App\Shared\Context\CompanyContext;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
require_once __DIR__.'/../../../packages/inventory-issue/bootstrap.php';
|
|
require_once __DIR__.'/../../../packages/inventory-return/bootstrap.php';
|
|
|
|
final class WorkshopStock
|
|
{
|
|
private array $allocationTables=[];
|
|
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
|
private function scope(string $right): array
|
|
{
|
|
WorkshopWorkflow::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($s['unit_id'],403);
|
|
$actor=$this->context->userId();$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
|
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.'.$right] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);return $s;
|
|
}
|
|
private function binding(array $s): array
|
|
{
|
|
$b=config('workshop_stock.synthetic_sources.'.implode(':',$s));
|
|
abort_unless($b&&(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$b['schema']??'')||(ComposedWorkshopStockBook::active()&&($b['schema']??null)==='public'))&&!isset($b['connection'])
|
|
&&($b['lots']??true)===false&&($b['reservations']??true)===false&&($b['spare_parts']??true)===false,503,'Fonte local de consumo ainda não ligada ou fora do recorte sem lotes/reservas/peças.');return $b;
|
|
}
|
|
private function lock(array $s): void {DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();}
|
|
private function allocationTable(array $b): ?string
|
|
{
|
|
$table=$b['schema'].'.ws_allocations';
|
|
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);
|
|
return $this->allocationTables[$table]?$table:null;
|
|
}
|
|
private function returnTable(array $b): ?string
|
|
{
|
|
$table=$b['schema'].'.ws_returns';if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);return $this->allocationTables[$table]?$table:null;
|
|
}
|
|
private function closureTable(array $b): ?string
|
|
{
|
|
$table=$b['schema'].'.ws_closures';
|
|
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=ComposedWorkshopStockBook::hasTable($table);
|
|
return $this->allocationTables[$table]?$table:null;
|
|
}
|
|
private function closure(array $s,array $b,int $id): ?object
|
|
{
|
|
return ($table=$this->closureTable($b))?ComposedWorkshopStockBook::table($table)->where($s)->where('proposal_id',$id)->first():null;
|
|
}
|
|
private function proposalResult(array $s,array $b,object $p): array
|
|
{
|
|
$closed=$this->closure($s,$b,(int)$p->id);$claim=$this->proposalClaim($b,(int)$p->id);
|
|
return ['id'=>(int)$p->id,'impact'=>$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR)),
|
|
'reason'=>$p->reason,'evidence'=>$p->evidence,'source_hash'=>$p->source_hash,
|
|
'state'=>$closed?->state??($claim?'confirmed':'pending'),'state_version'=>($closed||$claim)?2:1,
|
|
'movement_id'=>$claim?(int)$claim->movement_id:null,'replayed'=>true];
|
|
}
|
|
public function close(int $id,string $action,int $version,string $sourceHash,string $reason): array
|
|
{
|
|
abort_unless(in_array($action,['withdrawn','rejected'],true),422);
|
|
$right=$action==='withdrawn'?'prepare':'confirm';$s=$this->scope($right);$b=$this->binding($s);$actor=$this->context->userId();
|
|
return DB::transaction(function()use($id,$action,$version,$sourceHash,$reason,$right,$s,$b,$actor){
|
|
$this->lock($s);$this->scope($right);$table=$this->closureTable($b);abort_unless($table,503,'Encerramento de propostas ainda não instalado nesta fonte.');
|
|
$p=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);
|
|
abort_unless($action==='withdrawn'?(int)$p->preparer_id===$actor:(int)$p->preparer_id!==$actor,403,'Retirada exige o preparador; recusa exige outra identidade autorizada.');
|
|
abort_unless($version===1&&hash_equals($p->source_hash,$sourceHash),409,'A proposta apresentada mudou. Consulte novamente o histórico.');
|
|
if($old=$this->closure($s,$b,$id)){
|
|
abort_unless($old->state===$action&&(int)$old->actor_id===$actor&&$old->reason===$reason,409,'Proposta já encerrada por outra decisão.');
|
|
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>(int)$old->id,'replayed'=>true,'physical_effect_milli'=>'0'];
|
|
}
|
|
abort_if($this->proposalClaim($b,$id),409,'Tratamento já confirmado; exige correcção física própria.');
|
|
// Closing an obsolete preview deliberately does not execute or revalidate its physical effect.
|
|
$decision=ComposedWorkshopStockBook::table($table)->insertGetId($s+['proposal_id'=>$id,'actor_id'=>$actor,'source_hash'=>$sourceHash,'reason'=>$reason,'before_state'=>'pending','state'=>$action,'state_version'=>2,'created_at'=>now()]);
|
|
Audit::record('workshop_stock.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['area'=>'workshop_stock_proposal','record_id'=>$id,'decision_id'=>$decision,'version'=>2,'review_hash'=>$sourceHash,'reason'=>$reason]);
|
|
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>$decision,'replayed'=>false,'physical_effect_milli'=>'0'];
|
|
});
|
|
}
|
|
private function originClaimed(array $b,string $origin): bool
|
|
{
|
|
return ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&ComposedWorkshopStockBook::table($t)->where('origin_key',$origin)->exists());
|
|
}
|
|
private function proposalClaim(array $b,int $id): ?object
|
|
{
|
|
return ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?ComposedWorkshopStockBook::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?ComposedWorkshopStockBook::table($t)->where('proposal_id',$id)->first():null);
|
|
}
|
|
private function source(array $s,array $b,int $id,int $version,int $warehouse,bool $lock=false,bool $existingDelivery=false,string $event='consumed'): array
|
|
{
|
|
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->where('e.id',$id)->select('e.*');if($lock)$q->lockForUpdate();
|
|
$e=$q->first();abort_unless($e,403);$d=json_decode($e->data,true,512,JSON_THROW_ON_ERROR);
|
|
abort_unless(in_array((int)$e->order_id,$b['eligible_order_ids'],true),409,'Origem não reconciliada para este circuito; não se incorporam observações antigas automaticamente.');
|
|
abort_unless($e->kind==='material'&&($d['event']??null)===$event&&($d['owner']??null)==='company',422,'Ocorrência ou propriedade incompatível com o tratamento escolhido.');
|
|
abort_unless((int)$e->version===$version&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists(),409,'Origem antiga ou fora da autorização. Faça nova conferência.');
|
|
$decision=DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->first();abort_unless($decision&&(int)$decision->actor_id!==(int)$e->actor_id,409,'Consumo sem verificação técnica independente.');
|
|
$map=$b['resources'][$d['resource'].'|'.$d['unit']]??null;abort_unless($map&&in_array($warehouse,$map['warehouse_ids'],true),403,'Sem correspondência explícita de recurso/unidade/armazém.');
|
|
$item=ComposedWorkshopStockBook::table($b['schema'].'.stock_items')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$map['stock_item_id']])->first();
|
|
abort_unless($item&&$item->unit===$map['stock_unit']&&strtoupper(trim($item->unit))===$d['unit'],409,'Unidade de stock incompatível; sem conversão automática.');
|
|
abort_unless(ComposedWorkshopStockBook::table($b['schema'].'.warehouses')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$warehouse])->exists(),403);
|
|
try{$quantity=BigDecimal::of((string)$e->quantity)->multipliedBy(1000)->toScale(0)->toInt();}catch(\Brick\Math\Exception\MathException){abort(422,'Quantidade não representável exactamente em milésimos.');}abort_unless($quantity>0,422);
|
|
$origin=hash('sha256',json_encode([$s,$e->order_id,$e->origin],JSON_THROW_ON_ERROR));
|
|
// Do not deduct consumption again after an unlinked physical delivery/issue.
|
|
$unknownQuery=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$item->id,'m.warehouse_id'=>$warehouse])->where('m.quantity_milli','<',0)
|
|
->whereNotExists(fn($x)=>$x->selectRaw('1')->from($b['schema'].'.ws_claims AS c')->whereColumn('c.movement_id','m.id'));
|
|
$accounted='0';if($t=$this->allocationTable($b))$accounted.=' + (SELECT coalesce(sum(a.quantity_milli),0) FROM '.$t.' AS a WHERE a.movement_id=m.id)';
|
|
if($t=$this->returnTable($b))$accounted.=' + (SELECT coalesce(sum(r.quantity_milli),0) FROM '.$t.' AS r WHERE r.parent_movement_id=m.id)';
|
|
$unknownQuery->whereRaw('('.$accounted.') < -m.quantity_milli');
|
|
$unknown=$unknownQuery->exists();
|
|
abort_if($unknown&&!$existingDelivery,409,'Existe saída física sem correspondência reconciliada. Não se volta a abater a entrega como consumo.');
|
|
$book=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$item->id,'warehouse_id'=>$warehouse])->selectRaw('coalesce(sum(quantity_milli),0)::text AS quantity,coalesce(max(id),0)::text AS revision')->first();
|
|
abort_unless($existingDelivery||BigDecimal::of($book->quantity)->isGreaterThanOrEqualTo($quantity),409,'Stock físico insuficiente.');
|
|
return ['entry'=>(array)$e,'decision'=>(array)$decision,'binding'=>$b,'origin_key'=>$origin,'stock_item_id'=>(int)$item->id,'warehouse_id'=>$warehouse,'quantity_milli'=>$quantity,'unit'=>$item->unit,'book'=>(array)$book];
|
|
}
|
|
private function hash(array $v): string {return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
|
private function impact(array $v): array
|
|
{
|
|
$linked=isset($v['delivery']);$return=$linked&&($v['delivery']['returning']??false);$mode=$linked?($return?'return_existing_delivery':(($v['delivery']['partial']??false)?'allocate_existing_delivery':'link_existing_delivery')):'new_issue';
|
|
return ['mode'=>$mode,'physical_before'=>$v['book']['quantity'],'quantity_milli'=>$linked?'0':(string)$v['quantity_milli'],'return_quantity_milli'=>$return?(string)$v['quantity_milli']:null,'linked_quantity_milli'=>$linked?(string)$v['quantity_milli']:null,'unallocated_before'=>$v['delivery']['remaining_milli']??null,'unallocated_after'=>isset($v['delivery']['remaining_milli'])?(string)BigDecimal::of($v['delivery']['remaining_milli'])->minus($v['quantity_milli']):null,'existing_movement_id'=>$v['delivery']['movement']['id']??null,'physical_after'=>$return?(string)BigDecimal::of($v['book']['quantity'])->plus($v['quantity_milli']):($linked?$v['book']['quantity']:(string)BigDecimal::of($v['book']['quantity'])->minus($v['quantity_milli'])),'source_version'=>(int)$v['entry']['version'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']];
|
|
}
|
|
private function delivery(array $v,array $b,int $movementId,bool $lock=false,bool $partial=false,bool $returning=false): array
|
|
{
|
|
abort_unless(($b['eligible_deliveries'][$movementId]['order_id']??null)===(int)$v['entry']['order_id'],403,'Entrega sem elegibilidade explícita para esta ordem.');
|
|
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$movementId,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
|
$m=$q->first();abort_unless($m,403);
|
|
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_operations')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$m->stock_operation_id,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
|
$o=$q->first();abort_unless($o&&$o->kind==='issue'&&$m->movement_type==='issue'&&$m->equipment_id===null&&$m->spare_part_id===null,409,'Movimento incompatível com entrega simples.');
|
|
$snapshot=json_decode($m->snapshot,true,512,JSON_THROW_ON_ERROR);
|
|
abort_unless(($snapshot['inventory_operation_hash']??null)===$this->hash((array)$o),409,'Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');
|
|
abort_if(ComposedWorkshopStockBook::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']])->whereNotIn('movement_type',['receipt_in','issue','workshop_return_in'])->exists(),409,'Livro com estornos, transferências ou ajustes fora deste ensaio simples.');
|
|
$returns=$this->returnTable($b);
|
|
$q=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$v['stock_item_id'],'m.warehouse_id'=>$v['warehouse_id'],'m.movement_type'=>'workshop_return_in']);
|
|
if($returns)$q->whereNotExists(fn($x)=>$x->selectRaw('1')->from($returns.' AS r')->whereColumn('r.movement_id','m.id'));
|
|
abort_if($q->exists(),409,'Entrada de devolução sem decisão rastreável.');
|
|
$total=BigDecimal::of((string)$m->quantity_milli)->negated();
|
|
abort_unless($total->isPositive()&&$total->isEqualTo((string)$o->quantity_milli),409,'Quantidade da entrega incompatível com a operação.');
|
|
$allocations=$this->allocationTable($b);$used=$allocations?(string)ComposedWorkshopStockBook::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0';
|
|
$returned=$returns?(string)ComposedWorkshopStockBook::table($returns)->where('parent_movement_id',$movementId)->sum('quantity_milli'):'0';$remaining=$total->minus($used)->minus($returned);
|
|
if($partial||$returning){abort_unless($returning?(($b['stock_returns']??false)&&$returns):(($b['partial_delivery_links']??false)&&$allocations),503,'Tratamento de repartição/devolução não configurado neste ensaio.');abort_unless($remaining->isGreaterThanOrEqualTo($v['quantity_milli']),409,'Quantidade não associada insuficiente. Faça nova conferência.');}
|
|
else abort_unless($total->isEqualTo($v['quantity_milli'])&&BigDecimal::of($used)->isZero()&&BigDecimal::of($returned)->isZero(),409,'Entrega e consumo devem ter a mesma quantidade integral; use conferência de repartição quando aplicável.');
|
|
abort_unless(\Carbon\CarbonImmutable::parse($m->moved_at)->lessThanOrEqualTo(\Carbon\CarbonImmutable::parse($v['entry']['starts_at'])),409,'A entrega deve preceder o consumo observado.');
|
|
abort_if(ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->where('movement_id',$movementId)->exists(),409,'Entrega já ligada a outra decisão.');
|
|
$v['delivery']=['movement'=>(array)$m,'operation'=>(array)$o,'partial'=>$partial,'returning'=>$returning,'allocated_milli'=>$used,'returned_milli'=>$returned,'remaining_milli'=>(string)$remaining];return $v;
|
|
}
|
|
public function prepareDelivery(array $d,string $key,bool $partial=false,bool $returning=false): array
|
|
{
|
|
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();$request=['mode'=>$returning?'return_existing_delivery':($partial?'allocate_existing_delivery':'link_existing_delivery')]+$d;
|
|
return DB::transaction(function()use($s,$b,$actor,$d,$key,$request,$partial,$returning){$this->lock($s);$this->scope('prepare');
|
|
$old=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
|
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
|
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true,true,$returning?'returned':'consumed');
|
|
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já ligada; exige correcção própria.');
|
|
$v=$this->delivery($v,$b,$d['movement_id'],true,$partial,$returning);
|
|
$id=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],'command_key'=>$key,'request_hash'=>$this->hash($request),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
|
Audit::record($returning?'workshop_stock.return_prepared':'workshop_stock.delivery_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version'],'movement_id'=>$d['movement_id'],'physical_effect'=>$returning?(string)$v['quantity_milli']:'0']);
|
|
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
|
}
|
|
public function listing(int $page=1,int $proposalPage=1,string $status='all'): array
|
|
{
|
|
$s=$this->scope('view');$b=$this->binding($s);
|
|
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->join('wo_decisions AS d','d.entry_id','=','e.id')
|
|
->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->whereIn('o.id',$b['eligible_order_ids'])
|
|
->where(['e.kind'=>'material','e.outside_authority'=>false,'d.decision'=>'verified'])->whereRaw("e.data::jsonb->>'owner'='company'")->whereRaw(($b['stock_returns']??false)?"e.data::jsonb->>'event' IN ('consumed','returned')":"e.data::jsonb->>'event'='consumed'")
|
|
->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries AS n')->whereColumn('n.previous_id','e.id'))
|
|
->select(['e.id','e.order_id','e.origin','e.version','e.quantity','e.data'])->orderBy('e.id')->paginate(25,['*'],'page',$page);
|
|
$proposalQuery=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals AS p')->where($s);
|
|
$closureTable=$this->closureTable($b);
|
|
if(in_array($status,['withdrawn','rejected'],true)){
|
|
if($closureTable)$proposalQuery->whereExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id')->where('z.state',$status));
|
|
else $proposalQuery->whereRaw('false');
|
|
}
|
|
if(in_array($status,['pending','confirmed'],true))$proposalQuery->where(function($query)use($b,$status){
|
|
$tables=array_filter([$b['schema'].'.ws_claims',$this->allocationTable($b),$this->returnTable($b)]);
|
|
foreach($tables as $i=>$t){$method=$status==='confirmed'?($i===0?'whereExists':'orWhereExists'):'whereNotExists';$query->$method(fn($x)=>$x->selectRaw('1')->from($t.' AS c')->whereColumn('c.proposal_id','p.id'));}
|
|
});
|
|
if($status==='pending'&&$closureTable)$proposalQuery->whereNotExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id'));
|
|
$proposalPager=$proposalQuery->orderByDesc('p.id')->paginate(25,['p.id','p.entry_id','p.entry_version','p.preparer_id','p.reason','p.evidence','p.snapshot','p.source_hash','p.created_at'],'proposal_page',$proposalPage);$proposals=$proposalPager->getCollection();
|
|
$keys=array_map(fn($e)=>$this->hash([$s,$e->order_id,$e->origin]),$q->items());$linked=ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key');
|
|
if($t=$this->allocationTable($b))$linked=$linked->merge(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
|
if($t=$this->returnTable($b))$linked=$linked->merge(ComposedWorkshopStockBook::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
|
$physicalWarehouses=collect();$physicalDeliveries=collect();
|
|
if(ComposedWorkshopStockBook::active()){
|
|
$warehouses=[];$articles=[];foreach($q->items() as $entry){$data=json_decode($entry->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null;if($map){$warehouses=[...$warehouses,...$map['warehouse_ids']];$articles[]=$map['stock_item_id'];}}
|
|
$orders=array_column($q->items(),'order_id');$deliveries=[];foreach($b['eligible_deliveries'] as $id=>$known)if(in_array($known['order_id'],$orders,true))$deliveries[]=(int)$id;
|
|
$physicalWarehouses=ComposedWorkshopStockBook::table($b['schema'].'.warehouses')->where('tenant_id',$b['source_tenant_id'])->whereIn('id',array_unique($warehouses))->orderBy('id')->get(['id','code','name']);
|
|
$physicalDeliveries=ComposedWorkshopStockBook::table($b['schema'].'.stock_movements as m')->join($b['schema'].'.stock_operations as o','o.id','=','m.stock_operation_id')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.movement_type'=>'issue'])->whereIn('m.stock_item_id',array_unique($articles))->whereIn('m.id',$deliveries)->whereIn('m.warehouse_id',array_unique($warehouses))->orderBy('m.id')->get(['m.id','m.stock_item_id','o.reference','m.warehouse_id','m.quantity_milli']);
|
|
}
|
|
foreach($q->items() as $e){
|
|
$e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id;
|
|
if(ComposedWorkshopStockBook::active()){
|
|
$data=json_decode($e->data,true);$map=$b['resources'][$data['resource'].'|'.$data['unit']]??null;
|
|
$e->warehouses=$physicalWarehouses->whereIn('id',$map['warehouse_ids']??[])->values()->all();
|
|
$deliveries=[];foreach(($b['eligible_deliveries']??[]) as $id=>$known)if($known['order_id']===$e->order_id)$deliveries[]=(int)$id;
|
|
$e->deliveries=$physicalDeliveries->where('stock_item_id',$map['stock_item_id']??0)->whereIn('id',$deliveries)->whereIn('warehouse_id',$map['warehouse_ids']??[])->values()->all();
|
|
}
|
|
}
|
|
$claims=ComposedWorkshopStockBook::table($b['schema'].'.ws_claims')->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id'])->keyBy('proposal_id');
|
|
if($t=$this->allocationTable($b))foreach(ComposedWorkshopStockBook::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
|
if($t=$this->returnTable($b))foreach(ComposedWorkshopStockBook::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
|
$closed=$closureTable?ComposedWorkshopStockBook::table($closureTable)->where($s)->whereIn('proposal_id',$proposals->pluck('id'))->get()->keyBy('proposal_id'):collect();
|
|
foreach($proposals as $p){$p->impact=$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR));unset($p->snapshot);$p->movement_id=$claims->get($p->id)?->movement_id;$p->closure=$closed->get($p->id);$p->state=$p->closure?->state??($p->movement_id?'confirmed':'pending');$p->state_version=$p->state==='pending'?1:2;
|
|
$p->can_withdraw=$closureTable&&$p->state==='pending'&&(int)$p->preparer_id===$this->context->userId();$p->can_review=$p->state==='pending'&&(int)$p->preparer_id!==$this->context->userId();}
|
|
return ['sources'=>$q->items(),'pagination'=>['page'=>$q->currentPage(),'last_page'=>$q->lastPage(),'total'=>$q->total()],
|
|
'proposals'=>$proposals,'closure_supported'=>(bool)$closureTable,'proposal_pagination'=>['page'=>$proposalPager->currentPage(),'last_page'=>$proposalPager->lastPage(),'total'=>$proposalPager->total(),'status'=>$status],'notice'=>'Ensaio local: decisões individuais de saída, ligação, repartição e devolução. Nenhum efeito é automático; correcções físicas de material consumido continuam separadas.'];
|
|
}
|
|
public function prepare(array $d,string $key): array
|
|
{
|
|
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();
|
|
return DB::transaction(function()use($s,$b,$actor,$d,$key){$this->lock($s);$this->scope('prepare');
|
|
$old=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
|
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
|
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true);
|
|
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida; correcções exigem compensação própria.');
|
|
$id=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],
|
|
'command_key'=>$key,'request_hash'=>$this->hash($d),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
|
Audit::record('workshop_stock.prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version']]);
|
|
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
|
}
|
|
public function confirm(int $id,string $reason,bool $receivedInWarehouse=false): array
|
|
{
|
|
$s=$this->scope('confirm');$b=$this->binding($s);$actor=$this->context->userId();
|
|
return DB::transaction(function()use($s,$b,$actor,$id,$reason,$receivedInWarehouse){$this->lock($s);$this->scope('confirm');
|
|
$p=ComposedWorkshopStockBook::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);abort_if((int)$p->preparer_id===$actor,403,'Confirmação deve ser independente da preparação.');
|
|
abort_if($this->closure($s,$b,$id),409,'Proposta retirada ou recusada. Faça nova conferência; este original permanece encerrado.');
|
|
$old=$this->proposalClaim($b,$id);if($old)return ['ok'=>true,'replayed'=>true,'movement_id'=>(int)$old->movement_id,'claim_id'=>(int)$old->id];
|
|
$frozen=json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR);$link=isset($frozen['delivery']);$returning=$link&&($frozen['delivery']['returning']??false);$v=$this->source($s,$b,(int)$p->entry_id,(int)$p->entry_version,$frozen['warehouse_id'],true,$link,$returning?'returned':'consumed');
|
|
abort_unless(!$returning||$receivedInWarehouse,422,'Confirme expressamente a recepção física no armazém.');
|
|
$partial=$link&&($frozen['delivery']['partial']??false);
|
|
if($link)$v=$this->delivery($v,$b,(int)$frozen['delivery']['movement']['id'],true,$partial,$returning);
|
|
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida por outra decisão.');
|
|
abort_unless(hash_equals($p->source_hash,$this->hash($v)),409,'Origem, mapa ou livro mudou. Faça nova conferência.');
|
|
if($link&&!$returning){$operation=(int)$v['delivery']['operation']['id'];$out=['movement_id'=>(int)$v['delivery']['movement']['id']];}
|
|
else {
|
|
$operation=ComposedWorkshopStockBook::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>ComposedWorkshopStockBook::actor($actor),'responsible_id'=>ComposedWorkshopStockBook::actor($actor),'warehouse_id'=>$v['warehouse_id'],'stock_item_id'=>$v['stock_item_id'],
|
|
'kind'=>$returning?'workshop_return':'issue','reference'=>'SGF-WO-'.$v['origin_key'],'idempotency_key'=>(string)Str::uuid(),'payload_hash'=>$p->source_hash,'destination'=>'workshop-origin:'.$v['origin_key'],'reason'=>$reason,
|
|
'quantity_milli'=>$v['quantity_milli'],'theoretical_milli'=>$v['book']['quantity'],'ledger_version'=>$v['book']['revision'],'recorded_at'=>now(),
|
|
'snapshot'=>json_encode(($returning?['delivery_movement_id'=>(int)$v['delivery']['movement']['id'],'delivery_movement_hash'=>$this->hash($v['delivery']['movement']),'received_in_warehouse'=>$receivedInWarehouse]:[])+['workshop_origin'=>$v['entry'],'verification'=>$v['decision'],'proposal_id'=>$id,'preparer_id'=>$p->preparer_id,'scope'=>$s,'evidence'=>$p->evidence],JSON_THROW_ON_ERROR)]);
|
|
$writer=$returning?\Tempo\Inventory\DeliveryReturnWriter::class:\Tempo\Inventory\IssueMovementWriter::class;
|
|
$w=new $writer(ComposedWorkshopStockBook::connection(),$b['schema'],$b['source_tenant_id'],ComposedWorkshopStockBook::actor($actor),function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation));
|
|
}
|
|
$after=$this->impact($v)['physical_after'];
|
|
$claim=ComposedWorkshopStockBook::table($returning?$this->returnTable($b):($partial?$this->allocationTable($b):$b['schema'].'.ws_claims'))->insertGetId($s+($returning?['quantity_milli'=>$v['quantity_milli'],'parent_movement_id'=>(int)$v['delivery']['movement']['id']]:($partial?['quantity_milli'=>$v['quantity_milli']]:[]))+['origin_key'=>$v['origin_key'],'proposal_id'=>$id,'entry_id'=>$p->entry_id,'entry_version'=>$p->entry_version,'operation_id'=>$operation,'movement_id'=>$out['movement_id'],
|
|
'preparer_id'=>$p->preparer_id,'confirmer_id'=>$actor,'reason'=>$reason,'before_milli'=>$v['book']['quantity'],'after_milli'=>$after,'source_hash'=>$p->source_hash,'created_at'=>now()]);
|
|
Audit::record($returning?'workshop_stock.return_confirmed':($partial?'workshop_stock.delivery_allocated':($link?'workshop_stock.delivery_linked':'workshop_stock.confirmed')),$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['claim_id'=>$claim,'proposal_id'=>$id,'entry_id'=>$p->entry_id,'version'=>$p->entry_version,'movement_id'=>$out['movement_id'],'linked_quantity'=>$link?(string)$v['quantity_milli']:null,'remaining_after'=>($partial||$returning)?$this->impact($v)['unallocated_after']:null,'before'=>$v['book']['quantity'],'after'=>$after,'reason'=>$reason]);
|
|
return ['ok'=>true,'replayed'=>false,'movement_id'=>$out['movement_id'],'claim_id'=>$claim];});
|
|
}
|
|
}
|
|
|
|
|