commit melhorias sgf

This commit is contained in:
2026-11-11 09:00:17 +02:00
parent 0b05a1192c
commit 1d11a5e57b
736 changed files with 40746 additions and 210 deletions
+1
View File
@@ -13,6 +13,7 @@ require_once __DIR__.'/src/CycleSchema.php';
require_once __DIR__.'/src/PayableJournal.php';
require_once __DIR__.'/src/StockSchema.php';
require_once __DIR__.'/src/StockValuation.php';
require_once __DIR__.'/src/StockTransitEvidence.php';
require_once __DIR__.'/src/StockComposition.php';
require_once __DIR__.'/src/StockWorkspace.php';
require_once __DIR__.'/src/CycleWorkspace.php';
@@ -7,7 +7,7 @@ final class CostCorrectionSchema
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||!$db->getSchemaBuilder()->hasTable('gl_cost_sources')||$db->getSchemaBuilder()->hasTable('gl_cost_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
if((!str_starts_with($db->getDatabaseName(),'payable_sgf_')&&!LedgerSchema::externalHostComposition($db))||!$db->getSchemaBuilder()->hasTable('gl_cost_sources')||$db->getSchemaBuilder()->hasTable('gl_cost_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
$db->transaction(function()use($db){
$db->statement('CREATE TABLE gl_cost_correction_capability(id int PRIMARY KEY CHECK(id=1),contract text NOT NULL)');$db->table('gl_cost_correction_capability')->insert(['id'=>1,'contract'=>'COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1']);$db->unprepared('CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_cost_correction_capability FOR EACH ROW EXECUTE FUNCTION gl_append_only()');
$db->unprepared(<<<'SQL'
@@ -7,7 +7,7 @@ final class IndustrialCorrectionSchema
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')||!$db->getSchemaBuilder()->hasTable('gl_industrial_sources')||$db->getSchemaBuilder()->hasTable('gl_industrial_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
if((!str_starts_with($db->getDatabaseName(),'payable_sgf_')&&!LedgerSchema::externalHostComposition($db))||!$db->getSchemaBuilder()->hasTable('gl_industrial_sources')||$db->getSchemaBuilder()->hasTable('gl_industrial_correction_capability')||$db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists())throw new \LogicException('New empty SGF cost correction book required');
$db->transaction(function()use($db){
$db->statement('CREATE TABLE gl_industrial_correction_capability(id int PRIMARY KEY CHECK(id=1),contract text NOT NULL)');$db->table('gl_industrial_correction_capability')->insert(['id'=>1,'contract'=>'COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1']);$db->unprepared('CREATE TRIGGER gl_immutable BEFORE UPDATE OR DELETE ON gl_industrial_correction_capability FOR EACH ROW EXECUTE FUNCTION gl_append_only()');
$db->unprepared(<<<'SQL'
+10 -10
View File
@@ -14,11 +14,11 @@ trait LedgerCycle
$this->cycleReady();
if(in_array($kind,['allocation.confirmed','allocation.reversed'],true)){
$table=$kind==='allocation.confirmed'?'ps_allocations':'ps_reversals';
$row=$this->db->table($table)->where($this->scope)->where('id',$id)->first()??throw new \DomainException('Efeito financeiro confirmado indisponível.');
$row=$this->db->table($table)->where(str_starts_with($table,'ps_')?$this->financialScope():$this->scope)->where('id',$id)->first()??throw new \DomainException('Efeito financeiro confirmado indisponível.');
$base=($this->source)((int)$row->obligation_id);
if($kind==='allocation.confirmed'){
$decision=$this->db->table('ps_allocation_decisions')->where($this->scope)->where('id',$row->decision_id)->where('decision','confirmed')->first();
$payment=$this->db->table('ps_payment_decisions')->where($this->scope)->where('payment_id',$row->payment_id)->where('decision','confirmed')->first();
$decision=$this->db->table('ps_allocation_decisions')->where($this->financialScope())->where('id',$row->decision_id)->where('decision','confirmed')->first();
$payment=$this->db->table('ps_payment_decisions')->where($this->financialScope())->where('payment_id',$row->payment_id)->where('decision','confirmed')->first();
if(!$decision||!$payment)throw new \DomainException('Afectação sem confirmação financeira compatível.');
}else{
if(!$this->q('journals')->where('snapshot->source->effect_type','allocation.confirmed')->where('snapshot->source->source_id',(int)$row->allocation_id)->exists())throw new \DomainException('Contabilize primeiro a afectação original.');
@@ -51,7 +51,7 @@ trait LedgerCycle
$hash=$this->hash([$action,$d]);$old=$this->q('commands')->where(['actor_id'=>$this->actor,'command_key'=>$key])->first();
if($old){if(!hash_equals($old->request_hash,$hash))throw new \DomainException('Chave repetida com conteúdo diferente.');return json_decode($old->result,true,128,JSON_THROW_ON_ERROR)+['replayed'=>true];}
if($action==='effect-prepare'){
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
if(!in_array($d['effect_type'],['allocation.confirmed','allocation.reversed','sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed','workshop.direct_cost.validated','industry.cost.validated','stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'],true))throw new \InvalidArgumentException('Seleccione uma origem contabilística suportada.');
$result=$this->prepare(['obligation_id'=>$d['source_id']]+$d,$reason,$d['effect_type']);
}elseif(str_starts_with($action,'correct-'))$result=$this->correction($action,$d,$reason);
else $result=$this->periodCommand($action,$d,$reason);
@@ -94,21 +94,21 @@ trait LedgerCycle
{
$period=$this->latest('periods','period_key',$key);if($period->status!=='open')throw new \DomainException('Período já fechado.');
$h=hash_init('sha256');$pending=0;$facts=0;
foreach($this->db->table('pa_obligations')->where($this->scope)->orderBy('id')->cursor() as $o){
foreach($this->db->table('pa_obligations')->where($this->financialScope())->orderBy('id')->cursor() as $o){
$source=($this->source)((int)$o->id);hash_update($h,Payables::canonical($source));$facts++;
if(!$this->q('journals')->where('obligation_id',$o->id)->where('snapshot->source->effect_type','supplier_payable.recognised')->exists())$pending++;
}
foreach(['ps_allocations'=>'allocation.confirmed','ps_reversals'=>'allocation.reversed'] as $table=>$type){
foreach($this->db->table($table)->where($this->scope)->orderBy('id')->cursor() as $r){$facts++;hash_update($h,Payables::canonical((array)$r));if(!$this->q('journals')->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',(int)$r->id)->exists())$pending++;}
foreach($this->db->table($table)->where(str_starts_with($table,'ps_')?$this->financialScope():$this->scope)->orderBy('id')->cursor() as $r){$facts++;hash_update($h,Payables::canonical((array)$r));if(!$this->q('journals')->where('snapshot->source->effect_type',$type)->where('snapshot->source->source_id',(int)$r->id)->exists())$pending++;}
}
foreach(['ps_payments'=>'ps_payment_decisions','ps_proposals'=>'ps_allocation_decisions','ps_reversal_proposals'=>'ps_reversals'] as $table=>$decisions){
$fk=match($table){'ps_payments'=>'payment_id','ps_proposals'=>'proposal_id',default=>'proposal_id'};
$pending+=$this->db->table($table)->where($this->scope)->whereNotIn('id',$this->db->table($decisions)->where($this->scope)->select($fk))->count();
$pending+=$this->db->table($table)->where(str_starts_with($table,'ps_')?$this->financialScope():$this->scope)->whereNotIn('id',$this->db->table($decisions)->where($this->financialScope())->select($fk))->count();
}
$latest=$this->q('proposals')->selectRaw('max(id)')->groupBy('effect_key');
$pending+=$this->q('proposals')->whereIn('id',$latest)->whereNotIn('id',$this->q('journals')->select('proposal_id'))->whereNotIn('id',$this->q('reviews')->where('decision','rejected')->select('proposal_id'))->count();
foreach(['gl_accounts','gl_rules','gl_journals','gl_lines','gl_proposals','gl_reviews','gl_corrections','gl_correction_results','ps_payments','ps_payment_decisions','ps_proposals','ps_allocation_decisions','ps_reversal_proposals'] as $table){
hash_update($h,$table);foreach($this->db->table($table)->where($this->scope)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));
hash_update($h,$table);foreach($this->db->table($table)->where(str_starts_with($table,'ps_')?$this->financialScope():$this->scope)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));
}
$sum=$this->q('lines')->whereIn('journal_id',$this->q('journals')->where('snapshot->period->period_key',$key)->select('id'))->selectRaw('coalesce(sum(debit_minor),0) d,coalesce(sum(credit_minor),0) c')->first();
if((int)$sum->d!==(int)$sum->c)throw new \DomainException('Diário desequilibrado.');
@@ -157,8 +157,8 @@ trait LedgerCycle
$partReviews=$this->q('reviews')->whereIn('proposal_id',$ids)->get()->keyBy('proposal_id');$groups=array_map(fn($c)=>$c->id,$result['corrections']);$groupResults=$this->q('correction_results')->whereIn('correction_id',$groups)->get()->keyBy('correction_id');
$latestParts=$this->q('proposals')->whereIn('effect_key',$parts->pluck('effect_key')->all())->selectRaw('effect_key,max(capture_version) v')->groupBy('effect_key')->pluck('v','effect_key');
foreach($result['corrections'] as $c){$a=$parts->get($c->reverse_proposal_id);$b=$parts->get($c->replacement_proposal_id);$c->reverse_snapshot=$a->snapshot;$c->replacement_snapshot=$b->snapshot;$c->reverse_review=$partReviews->get($c->reverse_proposal_id);$c->replacement_review=$partReviews->get($c->replacement_proposal_id);$c->result=$groupResults->get($c->id);$c->current=(int)$a->capture_version===(int)$latestParts->get($a->effect_key)&&(int)$b->capture_version===(int)$latestParts->get($b->effect_key);}
foreach(['ps_allocations'=>'allocations','ps_reversals'=>'reversals'] as $table=>$name){$p=$this->db->table($table)->where($this->scope)->orderByDesc('id')->paginate(25,['*'],'page',$page);$result[$name]=$p->items();$result['pagination'][$name]=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];}
$p=$this->db->table('pa_obligations')->where($this->scope)->orderByDesc('id')->paginate(25,['*'],'page',$page);$result['obligations']=$p->items();$result['pagination']['obligations']=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];
foreach(['ps_allocations'=>'allocations','ps_reversals'=>'reversals'] as $table=>$name){$p=$this->db->table($table)->where(str_starts_with($table,'ps_')?$this->financialScope():$this->scope)->orderByDesc('id')->paginate(25,['*'],'page',$page);$result[$name]=$p->items();$result['pagination'][$name]=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];}
$p=$this->db->table('pa_obligations')->where($this->financialScope())->orderByDesc('id')->paginate(25,['*'],'page',$page);$result['obligations']=$p->items();$result['pagination']['obligations']=['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()];
return $result;
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ trait LedgerSales
private function captureSales(array $s,string $date,object $period,object $rule,int $amount):array
{
if(!$this->db->getSchemaBuilder()->hasTable('gl_sales_capability'))throw new \DomainException('Schema comercial não composto.');
$kind=$s['effect_type'];$codes=match($kind){'sales.invoice'=>['QA-CLIENTES','QA-VENDAS','QA-IMPOSTO-TESTE'],'receivable.allocated'=>['QA-LIQUIDACAO-CLIENTE','QA-CLIENTES'],'receivable.reversed'=>['QA-CLIENTES','QA-LIQUIDACAO-CLIENTE']};
$kind=$s['effect_type'];$codes=match($kind){'sales.invoice'=>['QA-CLIENTES','QA-VENDAS','QA-IMPOSTO-TESTE'],'receivable.allocated'=>['QA-LIQUIDACAO-CLIENTE','QA-CLIENTES'],'receivable.reversed'=>['QA-CLIENTES','QA-LIQUIDACAO-CLIENTE'],'receivable.reduced'=>['QA-REDUCAO-CLIENTE','QA-CLIENTES'],'receivable.reduction_reversed'=>['QA-CLIENTES','QA-REDUCAO-CLIENTE'],'cash.receipt_classified'=>['QA-CAIXA','QA-LIQUIDACAO-CLIENTE'],'cash.expense_confirmed'=>['QA-DESPESA-CAIXA','QA-CAIXA']};
$ids=[$rule->debit_account_id,$rule->credit_account_id];if($kind==='sales.invoice')$ids[]=$rule->tax_account_id;
if(count(array_unique($ids))!==count($ids))throw new \DomainException('Contas de movimento distintas necessárias.');
$accounts=$this->q('accounts')->whereIn('id',$ids)->get()->keyBy('id');$lines=[];
@@ -14,6 +14,6 @@ trait LedgerSales
$value=$kind==='sales.invoice'&&$n>0?($n===1?$s['base_minor']:$s['tax_minor']):$amount;
$lines[]=['account_id'=>$a->id,'account_code'=>$a->code,'debit_minor'=>$n===0?$value:0,'credit_minor'=>$n===0?0:$value];
}
return ['contract'=>'COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1','source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,'lines'=>$lines,'impact'=>['debit_minor'=>$amount,'credit_minor'=>$amount,'financial_balance_changed'=>false,'payment_created'=>false]];
return ['contract'=>str_starts_with($kind,'cash.')?'SGF-CASH-QA-v1':'COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1','source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,'lines'=>$lines,'impact'=>['debit_minor'=>$amount,'credit_minor'=>$amount,'financial_balance_changed'=>false,'payment_created'=>false]];
}
}
@@ -16,6 +16,14 @@ final class LedgerSchema
if($role->elevated)throw new \LogicException('LEDGER_ELEVATED_ROLE_REFUSED');
}
public static function externalHostComposition(ConnectionInterface $db):bool
{
self::assertSynthetic($db);
if(!preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$db->getDatabaseName(),$m)||!$db->getSchemaBuilder()->hasTable('gl_sgf_composition'))return false;
$r=$db->table('gl_sgf_composition')->first();
return $r&&$r->contract==='SGF-8085-NATIVE-COMPOSITION-v1'&&$r->host_database==='budget_core_sgf_'.$m[1]&&(int)$r->company_id>0&&(int)$r->unit_id>0;
}
public static function install(ConnectionInterface $db): void
{
self::assertSynthetic($db);
+18 -8
View File
@@ -13,7 +13,16 @@ final class PayableJournal
public const POLICY='QA-PAYABLE-JOURNAL-v1';
private const SUPPLIER_EFFECTS=['supplier_payable.recognised','allocation.confirmed','allocation.reversed','journal.reversal','journal.replacement'];
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,
private \Closure $authorize,private \Closure $readAuthorize,private \Closure $source,private ?\Closure $checkpoint=null,private ?\Closure $additionalSource=null,private ?\Closure $additionalPeriod=null) {}
private \Closure $authorize,private \Closure $readAuthorize,private \Closure $source,private ?\Closure $checkpoint=null,private ?\Closure $additionalSource=null,private ?\Closure $additionalPeriod=null,private ?array $financialScope=null) {
if($financialScope!==null){
if(array_keys($financialScope)!==['tenant_id','company_id','unit_id']||$financialScope['tenant_id']!==$scope['tenant_id']||!is_int($financialScope['company_id'])||!is_int($financialScope['unit_id'])||$financialScope['company_id']<0||$financialScope['unit_id']<0)throw new \InvalidArgumentException('Qualified financial source scope required.');
LedgerSchema::assertSynthetic($db);
if(!$db->getSchemaBuilder()->hasTable('gl_sgf_composition'))throw new \DomainException('Explicit SGF financial binding is absent.');
$binding=$db->table('gl_sgf_composition')->where('tenant_id',$scope['tenant_id'])->first();
if(!$binding||(int)$binding->company_id!==$scope['company_id']||(int)$binding->unit_id!==$scope['unit_id']||(int)$binding->financial_company_id!==$financialScope['company_id']||(int)$binding->financial_unit_id!==$financialScope['unit_id'])throw new \DomainException('Financial/ledger context binding changed.');
}
}
private function financialScope():array{return $this->financialScope??$this->scope;}
private function q(string $table){return $this->db->table('gl_'.$table)->where($this->scope);}
private function hash(array $data):string{return hash('sha256',Payables::canonical($data));}
private function step(string $name):void{if($this->checkpoint!==null)($this->checkpoint)($name);}
@@ -26,9 +35,9 @@ final class PayableJournal
private function capture(int $obligation,string $date,string $periodKey,string $ruleKey,string $kind='supplier_payable.recognised'):array
{
if(!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D',$date,$m)||!checkdate((int)$m[2],(int)$m[3],(int)$m[1]))throw new \InvalidArgumentException('Data de lançamento explícita inválida.');
$sales=in_array($kind,['sales.invoice','receivable.allocated','receivable.reversed'],true);$cost=in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true);
$sales=in_array($kind,['sales.invoice','receivable.allocated','receivable.reversed','receivable.reduced','receivable.reduction_reversed','cash.receipt_classified','cash.expense_confirmed'],true);$cost=in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true);
$industrial=in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true);
$stock=in_array($kind,['stock.receipt','stock.issue','stock.return'],true);
$stock=in_array($kind,['stock.receipt','stock.issue','stock.return','stock.adjustment','stock.supplier_return'],true);
$s=$sales||$cost||$industrial||$stock?($this->additionalSource!==null?($this->additionalSource)($kind,$obligation):throw new \DomainException('Origem adicional não composta neste host.')):($kind==='supplier_payable.recognised'?($this->source)($obligation):$this->cycleSource($kind,$obligation));
$amount=$s['amount_minor']??$s['principal_minor']??null;
if(($s['effect_type']??'')!==$kind||!($s['compatible']??false)||($s['currency']??'')!=='MZN'||!is_int($amount)||$amount<=0||(!$sales&&($s['tax_minor']??null)!==0)||($s['history_imported']??true))throw new \DomainException('Fonte incompatível com o ensaio contabilístico.');
@@ -38,11 +47,11 @@ final class PayableJournal
if($stock){if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books')||($s['stock_movement_on']??'')!==$date)throw new \DomainException('Valorização não composta ou data física incompatível.');}
if($industrial&&($s['period_start']<$period->starts_on||$s['period_end']>$period->ends_on))throw new \DomainException('Período não contém o lote seleccionado.');
if($period->status!=='open'||$date<$period->starts_on||$date>$period->ends_on)throw new \DomainException('Período fechado, incompatível ou alterado.');
if($rule->policy!==($stock?'QA-STOCK-VALUATION-v1':($sales?'QA-SALES-JOURNAL-v1':($cost?'QA-DIRECT-COST-JOURNAL-v1':($industrial?'QA-INDUSTRIAL-JOURNAL-v1':self::POLICY))))||$rule->effect_type!==$s['effect_type']||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to)throw new \DomainException('Regra contabilística ausente, incompatível ou fora de vigência.');
if($sales)return $this->captureSales($s,$date,$period,$rule,$amount);
if($rule->policy!==(str_starts_with($kind,'cash.')?'QA-CASH-JOURNAL-v1':($stock?'QA-STOCK-VALUATION-v1':($sales?'QA-SALES-JOURNAL-v1':($cost?'QA-DIRECT-COST-JOURNAL-v1':($industrial?'QA-INDUSTRIAL-JOURNAL-v1':self::POLICY)))))||$rule->effect_type!==$s['effect_type']||$rule->currency!=='MZN'||$date<$rule->valid_from||$date>$rule->valid_to)throw new \DomainException('Regra contabilística ausente, incompatível ou fora de vigência.');
if(str_starts_with($kind,'cash.')&&($s['permitted_period_key']!==$periodKey||$s['cash_effect_on']!==$date))throw new \DomainException('Data ou período de Caixa incompatível.');if($sales)return $this->captureSales($s,$date,$period,$rule,$amount);
$accounts=$this->q('accounts')->whereIn('id',[$rule->debit_account_id,$rule->credit_account_id])->get()->keyBy('id');
$debit=$accounts->get($rule->debit_account_id);$credit=$accounts->get($rule->credit_account_id);
$codes=match($kind){'stock.receipt'=>['QA-STOCK','QA-COMPENSACAO-STOCK'],'stock.issue'=>['QA-CONSUMO-STOCK','QA-STOCK'],'stock.return'=>['QA-STOCK','QA-CONSUMO-STOCK'],'industry.cost.validated','industry.cost.replacement'=>['QA-CUSTO-INDUSTRIAL','QA-COMPENSACAO-INDUSTRIAL'],'industry.cost.reversal'=>['QA-COMPENSACAO-INDUSTRIAL','QA-CUSTO-INDUSTRIAL'],'supplier_payable.recognised','journal.replacement'=>['QA-CONTRAPARTIDA','QA-FORNECEDOR'],'allocation.confirmed'=>['QA-FORNECEDOR','QA-LIQUIDACAO'],'allocation.reversed'=>['QA-LIQUIDACAO','QA-FORNECEDOR'],'journal.reversal'=>['QA-FORNECEDOR','QA-CONTRAPARTIDA'],'workshop.direct_cost.validated','workshop.direct_cost.replacement'=>['QA-CUSTO-DIRECTO','QA-COMPENSACAO-ENSAIO'],'workshop.direct_cost.reversal'=>['QA-COMPENSACAO-ENSAIO','QA-CUSTO-DIRECTO'],default=>throw new \DomainException('Tipo de efeito não autorizado.')};
$codes=match($kind){'stock.supplier_return'=>['QA-DEVOLUCAO-FORNECEDOR','QA-STOCK'],'stock.adjustment'=>['QA-DIFERENCA-INVENTARIO','QA-STOCK'],'stock.receipt'=>['QA-STOCK','QA-COMPENSACAO-STOCK'],'stock.issue'=>['QA-CONSUMO-STOCK','QA-STOCK'],'stock.return'=>['QA-STOCK','QA-CONSUMO-STOCK'],'industry.cost.validated','industry.cost.replacement'=>['QA-CUSTO-INDUSTRIAL','QA-COMPENSACAO-INDUSTRIAL'],'industry.cost.reversal'=>['QA-COMPENSACAO-INDUSTRIAL','QA-CUSTO-INDUSTRIAL'],'supplier_payable.recognised','journal.replacement'=>['QA-CONTRAPARTIDA','QA-FORNECEDOR'],'allocation.confirmed'=>['QA-FORNECEDOR','QA-LIQUIDACAO'],'allocation.reversed'=>['QA-LIQUIDACAO','QA-FORNECEDOR'],'journal.reversal'=>['QA-FORNECEDOR','QA-CONTRAPARTIDA'],'workshop.direct_cost.validated','workshop.direct_cost.replacement'=>['QA-CUSTO-DIRECTO','QA-COMPENSACAO-ENSAIO'],'workshop.direct_cost.reversal'=>['QA-COMPENSACAO-ENSAIO','QA-CUSTO-DIRECTO'],default=>throw new \DomainException('Tipo de efeito não autorizado.')};
if(!$debit||!$credit||!$debit->is_movement||!$credit->is_movement||$debit->currency!=='MZN'||$credit->currency!=='MZN'||$debit->id===$credit->id||[$debit->code,$credit->code]!==$codes)throw new \DomainException('Contas artificiais de movimento incompatíveis.');
return ['contract'=>$stock?'COMMON-LEDGER-STOCK-VALUATION-SIM-v1':($industrial?($kind==='industry.cost.validated'?'COMMON-LEDGER-INDUSTRIAL-SIM-v1':'COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1'):($cost?($kind==='workshop.direct_cost.validated'?'COMMON-LEDGER-DIRECT-COST-SIM-v1':'COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1'):($kind==='supplier_payable.recognised'?'COMMON-LEDGER-PAYABLE-SIM-v1':'COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1'))),'source'=>$s,'journal_on'=>$date,'currency'=>'MZN','period'=>(array)$period,'rule'=>(array)$rule,
'lines'=>[['account_id'=>$debit->id,'account_code'=>$debit->code,'debit_minor'=>$amount,'credit_minor'=>0],['account_id'=>$credit->id,'account_code'=>$credit->code,'debit_minor'=>0,'credit_minor'=>$amount]],
@@ -136,8 +145,8 @@ final class PayableJournal
($this->readAuthorize)();$journals=$this->q('journals');$focus=null;$salesInvoice=null;$costOrder=null;$industrialLot=null;
if(isset($options['industrial_lot_id'])){$industrialLot=$this->id($options['industrial_lot_id']);if($this->additionalSource===null||!$this->db->getSchemaBuilder()->hasTable('gl_industrial_sources')||!$this->db->table('gl_industrial_sources')->where($this->scope)->where('lot_id',$industrialLot)->exists())throw new \DomainException('Lote fora do âmbito composto.');$journals->whereIn('snapshot->source->effect_type',['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'])->where('snapshot->source->industrial_lot_id',$industrialLot);}
if(isset($options['cost_order_id'])){$costOrder=$this->id($options['cost_order_id']);if($this->additionalSource===null||!$this->db->getSchemaBuilder()->hasTable('gl_cost_sources')||!$this->db->table('gl_cost_sources')->where($this->scope)->where('order_id',$costOrder)->exists())throw new \DomainException('Custo fora do âmbito composto.');$journals->where('snapshot->source->cost_order_id',$costOrder);}
if(isset($options['sales_invoice_id'])){$salesInvoice=$this->id($options['sales_invoice_id']);if($this->additionalSource===null||!$this->db->table('si_invoices')->where($this->scope)->where('id',$salesInvoice)->exists())throw new \DomainException('Commercial source unavailable in this scope.');$journals->where('snapshot->source->invoice_id',$salesInvoice);}
if(isset($options['obligation_id'])){$focus=$this->id($options['obligation_id']);if(!$this->db->table('pa_obligations')->where($this->scope)->where('id',$focus)->exists())throw new \DomainException('Obrigação indisponível neste contexto.');$journals->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);}
if(isset($options['sales_invoice_id'])){$salesInvoice=$this->id($options['sales_invoice_id']);if($this->additionalSource===null||(!LedgerSchema::externalHostComposition($this->db)&&!$this->db->table('si_invoices')->where($this->scope)->where('id',$salesInvoice)->exists()))throw new \DomainException('Commercial source unavailable in this scope.');$journals->where('snapshot->source->invoice_id',$salesInvoice);}
if(isset($options['obligation_id'])){$focus=$this->id($options['obligation_id']);if(!$this->db->table('pa_obligations')->where($this->financialScope())->where('id',$focus)->exists())throw new \DomainException('Obrigação indisponível neste contexto.');$journals->where('obligation_id',$focus)->whereIn('snapshot->source->effect_type',self::SUPPLIER_EFFECTS);}
if(isset($options['period_key'])){if(!is_string($options['period_key'])||strlen($options['period_key'])>80)throw new \InvalidArgumentException('Período inválido.');$this->latest('periods','period_key',$options['period_key']);$journals->whereRaw("snapshot->'period'->>'period_key' = ?",[$options['period_key']]);}
$account=null;if(isset($options['account_id'])){$account=$this->q('accounts')->where('id',$this->id($options['account_id']))->first()??throw new \DomainException('Conta indisponível neste contexto.');$journals->whereIn('id',$this->q('lines')->where('account_id',$account->id)->select('journal_id'));}
$pager=(clone $journals)->orderByDesc('id')->paginate(25,['*'],'page',$page);$ids=array_map(fn($j)=>$j->id,$pager->items());
@@ -157,3 +166,4 @@ final class PayableJournal
});
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ final class SalesSchema
public static function install(ConnectionInterface $db):void
{
LedgerSchema::assertSynthetic($db);
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_'))throw new \LogicException('Sales host composition required');
if(!str_starts_with($db->getDatabaseName(),'payable_sgf_')&&!LedgerSchema::externalHostComposition($db))throw new \LogicException('Sales host composition required');
if($db->table('gl_proposals')->exists()||$db->table('gl_journals')->exists()||$db->getSchemaBuilder()->hasTable('gl_sales_capability'))throw new \LogicException('Fresh empty sales ledger required; no upgrade/reset');
$db->transaction(function()use($db){
$db->statement('ALTER TABLE gl_rules ADD COLUMN tax_account_id bigint NULL');
@@ -0,0 +1,31 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
use Tempo\Finance\Payables\Payables;
/** Immutable native review/execution facts; later eligible returns do not rewrite this fact. */
final class StockSupplierReturnEvidence
{
public function __construct(private ConnectionInterface $db,private array $scope){}
public function check(object $m,object $op,array $snapshot):array
{
if(!LedgerSchema::externalHostComposition($this->db)||$op->kind!=='supplier_return')throw new \DomainException('Devolução ao fornecedor fora da composição QA.');
$proof=$snapshot['disposition']??null;
$approved=is_array($proof)?$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$proof['approved_id']??0,'kind'=>'stock_event','state'=>'approved'])->first():null;
$executed=$approved?$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'record_key'=>$approved->record_key,'version'=>$approved->version+1,'state'=>'executed','kind'=>'stock_event'])->first():null;
if(!$approved||!$executed)throw new \DomainException('Faltam aprovação e execução nativas.');
$data=json_decode($approved->data,true,64,JSON_THROW_ON_ERROR);$done=json_decode($executed->data,true,64,JSON_THROW_ON_ERROR);$review=json_decode($approved->snapshot,true,64,JSON_THROW_ON_ERROR);
$source=$review['source']??null;$receiptItem=$source['receipt']['item_id']??null;
$line=is_int($receiptItem)?$this->db->table('goods_receipt_items')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$receiptItem])->first():null;
$receipt=$line?$this->db->table('goods_receipts')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$line->goods_receipt_id])->first():null;
$total=$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'movement_type'=>'supplier_return'])->where('snapshot->disposition->source->receipt->item_id',$receiptItem)->sum('quantity_milli');
if(!$line||!$receipt||$receipt->confirmed_at===null||$line->stock_item_id!==$m->stock_item_id||$receipt->warehouse_id!==$m->warehouse_id
||($data['kind']??null)!=='supplier_return'||($data['stock_item_id']??null)!==$m->stock_item_id||($data['warehouse_id']??null)!==$m->warehouse_id||($data['receipt_item_id']??null)!==$receiptItem
||($data['quantity_milli']??null)!==abs((int)$m->quantity_milli)||($done['stock_operation_id']??null)!==$op->id||$executed->created_by!==$m->moved_by
||$approved->created_by===$executed->created_by||($review['reviewed_by']??null)!==$approved->created_by||in_array($approved->created_by,$review['contributors']??[],true)
||($proof['record_key']??null)!==$approved->record_key||$op->idempotency_key!==$approved->record_key||($proof['financial_correction_created']??null)!==false
||Payables::canonical($proof['source']??null)!==Payables::canonical($source)||abs((int)$total)>(int)$line->accepted_milli)
throw new \DomainException('Devolução sem origem, quantidade, revisão e execução reconciliadas.');
return ['supplier_return_approved'=>(array)$approved,'supplier_return_executed'=>(array)$executed,'supplier_receipt_item'=>(array)$line,'supplier_receipt'=>(array)$receipt];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Tempo\Finance\GeneralLedger;
use Illuminate\Database\ConnectionInterface;
use Tempo\Finance\Payables\Payables;
/** No journal for an internal location change: every exception must reconcile to an owned transfer. */
final class StockTransitEvidence
{
public function __construct(private ConnectionInterface $db,private array $scope){}
public function ownership(object $book):array
{
$physical=(int)$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id])->sum('quantity_milli');
$owned=$physical;$transit=0;
if(!LedgerSchema::externalHostComposition($this->db)||!$this->db->getSchemaBuilder()->hasTable('sgf_stock_transit_events'))return ['owned_quantity_milli'=>$owned,'in_transit_milli'=>0];
foreach($this->db->table('sgf_stock_transit_events')->where($this->scope)->where(['stock_item_id'=>$book->stock_item_id,'source_warehouse_id'=>$book->warehouse_id,'action'=>'dispatch'])->orderBy('id')->cursor() as $event){
$movement=$this->db->table('stock_movements')->where('id',$event->movement_id)->first();$proof=$movement?$this->movement($movement):null;if(!$proof)throw new \DomainException('Propriedade interna não conferida.');
$returned=(int)$this->db->table('sgf_stock_transit_events')->where($this->scope)->where(['transfer_key'=>$event->transfer_key,'action'=>'return'])->sum('quantity_milli');$owned+=(int)$event->quantity_milli-$returned;$transit+=$proof['remaining_milli'];
}
foreach($this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_item_id'=>$book->stock_item_id])->where('warehouse_id','<>',$book->warehouse_id)->orderBy('id')->cursor() as $outside)if($this->movement($outside)===null)throw new \DomainException('Movimento fora do livro de origem exige conferência própria.');
return ['owned_quantity_milli'=>$owned,'in_transit_milli'=>$transit];
}
public function movement(object $m):?array
{
$snapshot=json_decode($m->snapshot,true);if(!isset($snapshot['sgf_internal_transit']))return null;
if(!LedgerSchema::externalHostComposition($this->db)||!$this->db->getSchemaBuilder()->hasTable('sgf_stock_transit_events'))throw new \DomainException('Trânsito sem protocolo autorizado.');
$t=$snapshot['sgf_internal_transit'];$events=$this->db->table('sgf_stock_transit_events')->where($this->scope)->where('transfer_key',$t['transfer_key']??'')->orderBy('id');
$dispatch=(clone $events)->where('action','dispatch')->first();if(!$dispatch||$dispatch->stock_item_id!==$m->stock_item_id||($t['scope']??null)!==$this->scope)throw new \DomainException('Origem de propriedade em trânsito não demonstrada.');
$book=$this->db->table('gl_stock_books')->where($this->scope)->where(['stock_item_id'=>$m->stock_item_id,'warehouse_id'=>$dispatch->source_warehouse_id])->first();
if(!$book)throw new \DomainException('Trânsito sem livro de origem.');$rate=$this->db->table('gl_stock_rates')->where($this->scope)->where('book_id',$book->id)->orderByDesc('version')->first();$review=$rate?$this->db->table('gl_stock_rate_reviews')->where($this->scope)->where('rate_id',$rate->id)->where('decision','approved')->first():null;
if(!$rate||!$review||$rate->prepared_by===$review->reviewed_by||$rate->person===$review->person)throw new \DomainException('Trânsito sem taxa de propriedade revista.');
$done=0;$seen=0;$found=false;$hash=hash_init('sha256');
foreach($events->cursor() as $e){
if($e->action==='dispatch')$seen++;else $done+=(int)$e->quantity_milli;
$row=$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$e->movement_id])->first();$s=$row?json_decode($row->snapshot,true):null;
$warehouse=$e->action==='receive'?$dispatch->destination_warehouse_id:$dispatch->source_warehouse_id;$sign=$e->action==='dispatch'?-1:1;
if(!$row||$e->stock_item_id!==$dispatch->stock_item_id||$e->source_warehouse_id!==$dispatch->source_warehouse_id||$e->destination_warehouse_id!==$dispatch->destination_warehouse_id||$row->stock_item_id!==$dispatch->stock_item_id||$row->warehouse_id!==$warehouse||(int)$row->quantity_milli!==$sign*(int)$e->quantity_milli||$row->moved_by!==$e->actor_id||$row->movement_type!==($sign<0?'transfer_out':'transfer_in')||($s['sgf_internal_transit']['transfer_key']??null)!==$dispatch->transfer_key||($s['sgf_internal_transit']['scope']??null)!==$this->scope||($e->action==='receive'&&($e->actor_id===$dispatch->actor_id||$e->host_actor_id===$dispatch->host_actor_id)))throw new \DomainException('Parcela interna sem correspondência física ou independência.');
$operation=$this->db->table('stock_operations')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$row->stock_operation_id])->first();
if(!$operation||$operation->kind!=='transfer'||$operation->idempotency_key!==$e->command_key||$operation->payload_hash!==$e->request_hash||$operation->created_by!==$e->actor_id||(int)$operation->quantity_milli!==(int)$e->quantity_milli||Payables::canonical(json_decode($e->snapshot,true))!==Payables::canonical($s))throw new \DomainException('Transferência sem operação única auditada.');
if($row->id===$m->id)$found=true;hash_update($hash,Payables::canonical([(array)$e,(array)$row,(array)$operation]));
}
if(!$found||$seen!==1||$done>(int)$dispatch->quantity_milli)throw new \DomainException('Trânsito duplicado ou sobre-recebido.');
return ['classification'=>'internal_owned_transfer','journal_required'=>false,'transfer_key'=>$dispatch->transfer_key,'remaining_milli'=>(int)$dispatch->quantity_milli-$done,'unit_minor'=>$rate->unit_minor,'rate_id'=>$rate->id,'evidence_hash'=>hash_final($hash),'ownership_change_minor'=>0];
}
}
+46 -8
View File
@@ -1,6 +1,8 @@
<?php
namespace Tempo\Finance\GeneralLedger;
require_once __DIR__.'/StockSupplierReturnEvidence.php';
use Brick\Math\BigRational;
use Illuminate\Database\ConnectionInterface;
use Tempo\Finance\Payables\Payables;
@@ -8,14 +10,15 @@ use Tempo\Finance\Payables\Payables;
/** Selected QA physical facts, never a second physical writer or inventory balance. */
final class StockValuation
{
public const TYPES=['receipt_in'=>'stock.receipt','issue'=>'stock.issue','workshop_return_in'=>'stock.return'];
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,private \Closure $authorize,private \Closure $readAuthorize){}
public const TYPES=['receipt_in'=>'stock.receipt','issue'=>'stock.issue','workshop_return_in'=>'stock.return','adjustment_out'=>'stock.adjustment','supplier_return'=>'stock.supplier_return'];
public function __construct(private ConnectionInterface $db,private array $scope,private int $actor,private string $person,private \Closure $authorize,private \Closure $readAuthorize,private ?\Closure $materialIsolation=null){}
private function q(string $name){return $this->db->table('gl_stock_'.$name)->where($this->scope);}
private function hash(mixed $v):string{return hash('sha256',Payables::canonical($v));}
private function ready():void{
LedgerSchema::assertSynthetic($this->db);($this->readAuthorize)();
if(!$this->db->getSchemaBuilder()->hasTable('gl_stock_books'))throw new \DomainException('Valorização de stock não composta.');
if($this->db->table('gl_journals')->where($this->scope)->whereIn('snapshot->source->effect_type',['workshop.direct_cost.validated','industry.cost.validated'])->exists())throw new \DomainException('Livro exclusivo: custos directos/industriais já reconhecidos; não duplicar material.');
if($this->materialIsolation!==null)($this->materialIsolation)();
elseif($this->db->table('gl_journals')->where($this->scope)->whereIn('snapshot->source->effect_type',['workshop.direct_cost.validated','industry.cost.validated'])->exists())throw new \DomainException('Livro exclusivo: custos directos/industriais já reconhecidos; não duplicar material.');
}
private function book(int $id):object{return $this->q('books')->where('id',$id)->first()??throw new \DomainException('Livro de stock fora do contexto.');}
private function movements(object $book){return $this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id]);}
@@ -27,6 +30,10 @@ final class StockValuation
});
// No list of all movement IDs, and no transfer of prior full snapshots.
$prior=$this->movements($book)->where('id','<',$movement->id);
// Internal transfers carry ownership without an expense journal. Validate every excluded
// physical row, not merely a caller-supplied movement label.
foreach((clone $prior)->whereNotNull('snapshot->sgf_internal_transit')->cursor() as $internal)(new StockTransitEvidence($this->db,$this->scope))->movement($internal);
$prior->whereNull('snapshot->sgf_internal_transit');
$count=(clone $prior)->count();if($count===0)return [];
$qualified=[];foreach($this->scope as$key=>$value)$qualified['j.'.$key]=$value;
$valued=$this->db->table('gl_journals as j')->join('stock_movements as pm',function($join)use($book,$movement){
@@ -92,7 +99,7 @@ SQL);
$this->ready();if(!in_array($kind,self::TYPES,true))throw new \DomainException('Tipo físico não suportado.');
$m=$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$id])->first()??throw new \DomainException('Movimento físico fora do contexto.');
$b=$this->q('books')->where(['stock_item_id'=>$m->stock_item_id,'warehouse_id'=>$m->warehouse_id])->first()??throw new \DomainException('Artigo/armazém não composto neste contexto.');
if((self::TYPES[$m->movement_type]??null)!==$kind||(int)$m->quantity_milli===0||($kind==='stock.issue')!==((int)$m->quantity_milli<0))throw new \DomainException('Tipo/sinal físico incompatível.');
if((self::TYPES[$m->movement_type]??null)!==$kind||(int)$m->quantity_milli===0||in_array($kind,['stock.issue','stock.adjustment','stock.supplier_return'],true)!==((int)$m->quantity_milli<0))throw new \DomainException('Tipo/sinal físico incompatível.');
$rate=$this->q('rates')->where('book_id',$b->id)->orderByDesc('version')->first();$review=$rate?$this->q('rate_reviews')->where('rate_id',$rate->id)->first():null;
if(!$rate||!$review||$review->decision!=='approved'||$review->reviewed_by===$rate->prepared_by||$review->person===$rate->person)throw new \DomainException('Taxa actual completa e validada distintamente obrigatória.');
$item=$this->db->table('stock_items')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->stock_item_id])->first();
@@ -106,18 +113,35 @@ SQL);
$dependencies=['line'=>(array)$line,'receipt'=>(array)$receipt];
}else{
$operation=$this->db->table('stock_operations')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$m->stock_operation_id])->first();
if(!$operation||$operation->stock_item_id!==$m->stock_item_id||$operation->warehouse_id!==$m->warehouse_id||(int)$operation->quantity_milli!==abs((int)$m->quantity_milli)||$operation->created_by!==$m->moved_by)throw new \DomainException('Operação física incompatível.');
if(!$operation||$operation->stock_item_id!==$m->stock_item_id||$operation->warehouse_id!==$m->warehouse_id||($kind!=='stock.adjustment'&&(int)$operation->quantity_milli!==abs((int)$m->quantity_milli))||$operation->created_by!==$m->moved_by)throw new \DomainException('Operação física incompatível.');
$dependencies=['operation'=>(array)$operation];
if($kind==='stock.issue'){
if($kind==='stock.supplier_return'){
$dependencies+=(new StockSupplierReturnEvidence($this->db,$this->scope))->check($m,$operation,$snapshot);
}elseif($kind==='stock.adjustment'){
if(!LedgerSchema::externalHostComposition($this->db)||$operation->kind!=='adjustment')throw new \DomainException('Ajuste fora do recorte composto.');
$count=$this->db->table('stock_operations')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$operation->count_id])->first();$plan=json_decode($operation->snapshot,true)['lot_adjustment']??null;$observed=$count?json_decode($count->snapshot,true)['lot_count']??null:null;
if(!$count||$count->kind!=='count'||$count->created_by===$operation->created_by||$count->responsible_id===$operation->created_by||$count->stock_item_id!==$m->stock_item_id||$count->warehouse_id!==$m->warehouse_id||!$plan||!$observed||$plan['source_hash']!==$observed['source_hash']||$plan['increase_milli']!==0||$plan['decrease_milli']!==abs((int)$m->quantity_milli)||(int)$operation->quantity_milli!==(int)$count->quantity_milli||(int)$count->quantity_milli-(int)$operation->theoretical_milli!==(int)$m->quantity_milli)throw new \DomainException('Ajuste sem contagem por lotes e revisão distinta.');
$partition=$this->db->table('inventory_lot_allocations')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_movement_id'=>$m->id])->sum('quantity_milli');
if((int)$partition!==(int)$m->quantity_milli)throw new \DomainException('Ajuste não reconciliado com os lotes.');
$dependencies+=['count'=>(array)$count,'lot_plan'=>$plan];
}elseif($kind==='stock.issue'){
if($operation->kind!=='issue'||!preg_match('/^[a-f0-9]{64}$/D',$snapshot['inventory_operation_hash']??''))throw new \DomainException('Saída original incompleta.');
}else{
$parentId=$snapshot['delivery_movement_id']??null;$parent=is_int($parentId)?$this->movements($b)->where('id',$parentId)->first():null;
$total=$this->movements($b)->where('movement_type','workshop_return_in')->where('snapshot->delivery_movement_id',$parentId)->sum('quantity_milli');
if(!$parent||$parent->movement_type!=='issue'||$operation->kind!=='workshop_return'||!($snapshot['received_in_warehouse']??false)||$total>abs((int)$parent->quantity_milli)||!preg_match('/^[a-f0-9]{64}$/D',$snapshot['inventory_return_hash']??''))throw new \DomainException('Devolução sem saída original/limite/recepção física.');
if(isset($snapshot['workshop_origin'])){
if(!LedgerSchema::externalHostComposition($this->db)||!$this->db->getSchemaBuilder()->hasTable('ws_returns')||($snapshot['scope']??null)!==$this->scope)throw new \DomainException('Devolução Oficina sem composição física qualificada.');
$claim=$this->db->table('ws_returns')->where($this->scope)->where('movement_id',$m->id)->first();$proposal=$claim?$this->db->table('ws_proposals')->where($this->scope)->where('id',$claim->proposal_id)->first():null;$frozen=$proposal?json_decode($proposal->snapshot,true):null;
$allocated=$this->db->table('ws_allocations')->where($this->scope)->where('movement_id',$parentId)->sum('quantity_milli');
if(!$claim||!$proposal||$claim->preparer_id===$claim->confirmer_id||$claim->parent_movement_id!==$parentId||(int)$claim->quantity_milli!==(int)$m->quantity_milli||$claim->operation_id!==$operation->id||$claim->source_hash!==$proposal->source_hash||$claim->entry_id!==($snapshot['workshop_origin']['id']??null)||$proposal->entry_version!==($snapshot['workshop_origin']['version']??null)||($frozen['delivery']['movement']['id']??null)!==$parentId||$allocated+$total>abs((int)$parent->quantity_milli)||Payables::canonical($frozen['entry']??null)!==Payables::canonical($snapshot['workshop_origin'])||($snapshot['verification']['actor_id']??null)===($snapshot['workshop_origin']['actor_id']??null))throw new \DomainException('Devolução Oficina sem proposta individual, revisão distinta ou limite reconciliado.');
$dependencies+=['parent'=>(array)$parent,'workshop_proposal'=>(array)$proposal,'workshop_return'=>(array)$claim];
}else{
$declaration=$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$snapshot['declaration_id']??0])->first();
$confirmation=$declaration?$this->db->table('supply_records')->where(['tenant_id'=>$this->scope['tenant_id'],'record_key'=>$declaration->record_key,'version'=>2,'state'=>'confirmed'])->first():null;
if(!$declaration||!$confirmation||$declaration->created_by===$confirmation->created_by||$confirmation->created_by!==$m->moved_by)throw new \DomainException('Devolução sem conferência distinta.');
$dependencies+=['parent'=>(array)$parent,'declaration'=>(array)$declaration,'confirmation'=>(array)$confirmation];
}
}
}
try{$amount=BigRational::of((string)abs((int)$m->quantity_milli))->multipliedBy($rate->unit_minor)->dividedBy(1000)->toBigInteger()->toInt();}catch(\Throwable $e){throw new \DomainException('Valor não representável em cêntimos exactos.',0,$e);}
@@ -145,18 +169,32 @@ SQL);
->orderByRaw("snapshot->'source'->>'stock_movement_id',id")->get()->keyBy('movement_id');
foreach($batch as$m){
$facts++;hash_update($h,Payables::canonical((array)$m));
try{$internal=(new StockTransitEvidence($this->db,$this->scope))->movement($m);if($internal!==null){hash_update($h,Payables::canonical($internal));continue;}}catch(\DomainException){$pending++;continue;}
try{$s=$this->source(self::TYPES[$m->movement_type]??'',(int)$m->id);hash_update($h,$s['stock_fact_revision']);$j=$posted->get($m->id);if(!$j||json_decode($j->fact_revision??'null',true)!==$s['stock_fact_revision'])$pending++;}catch(\DomainException){$pending++;}
}
});
// A transferred item may be at a destination outside its origin valuation book.
// Any unrelated out-of-book movement is an explicit pending fact, never silently
// omitted from closing or classified as an internal transfer by label alone.
if(LedgerSchema::externalHostComposition($this->db))foreach($this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'stock_item_id'=>$b->stock_item_id])->where('warehouse_id','<>',$b->warehouse_id)->whereDate('moved_at','>=',$period['starts_on'])->whereDate('moved_at','<=',$period['ends_on'])->orderBy('id')->cursor() as $outside){
hash_update($h,Payables::canonical((array)$outside));try{if((new StockTransitEvidence($this->db,$this->scope))->movement($outside)===null)$pending++;}catch(\DomainException){$pending++;}
}
}
// Destination movements are outside the origin valuation book, but remain part of
// the same ownership proof and must not disappear from period conference.
if(LedgerSchema::externalHostComposition($this->db)&&$this->db->getSchemaBuilder()->hasTable('sgf_stock_transit_events'))foreach($this->db->table('sgf_stock_transit_events')->where($this->scope)->orderBy('id')->cursor() as $event){
hash_update($h,Payables::canonical((array)$event));$m=$this->db->table('stock_movements')->where(['tenant_id'=>$this->scope['tenant_id'],'id'=>$event->movement_id])->first();
try{if(!$m||(new StockTransitEvidence($this->db,$this->scope))->movement($m)===null)$pending++;}catch(\DomainException){$pending++;}
}
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function page(int $bookId,int $page=1,int $perPage=25):array
{
$this->ready();if($page<1||$page>100000||!in_array($perPage,[25,100],true))throw new \InvalidArgumentException('Página inválida.');$b=$this->book($bookId);$q=$this->movements($b);$count=(clone$q)->count();$rows=[];
foreach($q->orderBy('id')->offset(($page-1)*$perPage)->limit($perPage)->get()as$m){$row=['movement'=>(array)$m,'source'=>null,'pending'=>null];try{$row['source']=$this->source(self::TYPES[$m->movement_type]??'',(int)$m->id);}catch(\DomainException $e){$row['pending']=$e->getMessage();}$rows[]=$row;}
foreach($q->orderBy('id')->offset(($page-1)*$perPage)->limit($perPage)->get()as$m){$row=['movement'=>(array)$m,'source'=>null,'pending'=>null];try{$internal=(new StockTransitEvidence($this->db,$this->scope))->movement($m);if($internal!==null)$row['nonfinancial_reconciliation']=$internal;else $row['source']=$this->source(self::TYPES[$m->movement_type]??'',(int)$m->id);}catch(\DomainException $e){$row['pending']=$e->getMessage();}$rows[]=$row;}
$rates=$this->q('rates')->where('book_id',$b->id)->orderByDesc('version')->limit(25)->get();$reviews=$this->q('rate_reviews')->whereIn('rate_id',$rates->pluck('id')->all())->get()->keyBy('rate_id');foreach($rates as$r)$r->review=$reviews->get($r->id);
return ['book'=>(array)$b,'rows'=>$rows,'pagination'=>['page'=>$page,'per_page'=>$perPage,'total'=>$count],'rates'=>$rates->all(),'physical_balance_milli'=>(int)$this->movements($b)->sum('quantity_milli'),'does_not_move_stock'=>true];
try{$ownership=(new StockTransitEvidence($this->db,$this->scope))->ownership($b);}catch(\DomainException $e){$ownership=['owned_quantity_milli'=>null,'in_transit_milli'=>null,'ownership_pending'=>$e->getMessage()];}
return ['book'=>(array)$b,'rows'=>$rows,'pagination'=>['page'=>$page,'per_page'=>$perPage,'total'=>$count],'rates'=>$rates->all(),'physical_balance_milli'=>(int)$this->movements($b)->sum('quantity_milli'),'ownership'=>$ownership,'does_not_move_stock'=>true];
}
public function receipt(string $key):?array
{
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/IssueMovementWriter.php';
+8
View File
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-issue",
"version": "0.1.0",
"files": {
"bootstrap.php": "3e6c7c698279f3ffb865131e79b3f89e5ec3e4aa9114782df62e17712f48df6b",
"src/IssueMovementWriter.php": "36d59bce53b8447516399974fc7d08beaa5d92b7b45a416014bd855e03fdf409"
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tempo\Inventory;
use Brick\Math\BigDecimal;
use Closure;
use Illuminate\Database\ConnectionInterface;
/** One physical issue, within the host's authorised transaction. No independent balance. */
final class IssueMovementWriter
{
public function __construct(private ConnectionInterface $db,private string $schema,private int $tenant,private int $actor,private Closure $authorize)
{
if(!preg_match('/^[a-z][a-z0-9_]{0,62}$/D',$schema)||$tenant<1||$actor<1)throw new \InvalidArgumentException('Invalid issue context');
}
private function host(): void {if($this->db->transactionLevel()<1)throw new \LogicException('Issue requires the host transaction');($this->authorize)();}
private function operation(int $id,bool $lock=false): object
{
$q=$this->db->table($this->schema.'.stock_operations')->where(['tenant_id'=>$this->tenant,'id'=>$id]);if($lock)$q->lockForUpdate();
$o=$q->first();abort_unless($o,403);abort_unless($o->kind==='issue'&&(int)$o->created_by===$this->actor&&BigDecimal::of((string)$o->quantity_milli)->isPositive(),409,'Operação incompatível com saída física.');return $o;
}
public function preview(int $id): string {$this->host();return hash('sha256',json_encode((array)$this->operation($id),JSON_THROW_ON_ERROR));}
public function write(int $id,string $hash): array
{
$this->host();$o=$this->operation($id);
// Lock the actual warehouse, including a previously empty book. Hosts keep their company lock too.
$warehouse=$this->db->table($this->schema.'.warehouses')->where(['tenant_id'=>$this->tenant,'id'=>$o->warehouse_id])->lockForUpdate()->first();abort_unless($warehouse,403);
abort_unless($this->db->table($this->schema.'.stock_items')->where(['tenant_id'=>$this->tenant,'id'=>$o->stock_item_id])->lockForUpdate()->first(),403);
$o=$this->operation($id,true);abort_unless(hash_equals($hash,hash('sha256',json_encode((array)$o,JSON_THROW_ON_ERROR))),409,'A operação mudou. Faça nova conferência.');
$q=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'warehouse_id'=>$o->warehouse_id]);$old=$q->first();
$negative=(string)BigDecimal::of((string)$o->quantity_milli)->negated();
if($old){$s=json_decode($old->snapshot,true,512,JSON_THROW_ON_ERROR);abort_unless(($s['inventory_operation_hash']??null)===$hash&&(string)$old->quantity_milli===$negative&&(int)$old->stock_item_id===(int)$o->stock_item_id,409,'Saída existente incompatível; exige reconciliação.');return ['movement_id'=>(int)$old->id,'replayed'=>true];}
$book=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id])->selectRaw('coalesce(sum(quantity_milli),0) AS quantity,coalesce(max(id),0) AS revision')->first();
abort_unless((string)$o->ledger_version===(string)$book->revision,409,'O livro mudou. Faça nova conferência.');
abort_unless(BigDecimal::of((string)$book->quantity)->isGreaterThanOrEqualTo((string)$o->quantity_milli),409,'Stock físico insuficiente.');
$snapshot=json_decode($o->snapshot,true,512,JSON_THROW_ON_ERROR)+['reference'=>$o->reference,'reason'=>$o->reason,'inventory_operation_hash'=>$hash];
$movement=$this->db->table($this->schema.'.stock_movements')->insertGetId(['tenant_id'=>$this->tenant,'stock_operation_id'=>$id,'stock_item_id'=>$o->stock_item_id,'warehouse_id'=>$o->warehouse_id,
'equipment_id'=>$o->equipment_id??null,'spare_part_id'=>$o->spare_part_id??null,'moved_by'=>$this->actor,'movement_type'=>'issue','quantity_milli'=>$negative,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'moved_at'=>now()]);
return ['movement_id'=>(int)$movement,'replayed'=>false];
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/src/ReceiptMovementWriter.php';
+8
View File
@@ -0,0 +1,8 @@
{
"id": "tempo-inventory-receipt-write",
"version": "0.1.0",
"files": {
"bootstrap.php": "b6ea8939536008c9277724216df2a968f416d9e7374189587a1172c02ecacb75",
"src/ReceiptMovementWriter.php": "279cec2ec915e27cf431355fca1e36f21cb3c15a77d02834723a41ec7a6f8f40"
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tempo\Inventory;
use Closure;
use Illuminate\Database\ConnectionInterface;
/** Receipt effects only. Host owns receipt eligibility, company lock, lots and audit transaction. */
final class ReceiptMovementWriter
{
public function __construct(private ConnectionInterface $db, private string $schema, private int $tenant, private int $actor, private Closure $authorize)
{
if(!preg_match('/^[a-z][a-z0-9_]{0,62}$/D',$schema)||$tenant<1||$actor<1)throw new \InvalidArgumentException('Invalid inventory context');
}
public function preview(int $lineId): array
{
$this->checkHost();$facts=$this->facts($lineId,false);
return ['source_hash'=>$this->hash($facts),'receipt_item_id'=>$lineId,'accepted_milli'=>(string)$facts['accepted_milli'],
'stock_item_id'=>(int)$facts['stock_item_id'],'warehouse_id'=>(int)$facts['warehouse_id']];
}
public function write(int $lineId, string $expectedHash): array
{
$this->checkHost();$facts=$this->facts($lineId,true);$hash=$this->hash($facts);
abort_unless(hash_equals($hash,$expectedHash),409,'A origem da recepção mudou. Faça nova conferência.');
$existing=$this->db->table($this->schema.'.stock_movements')->where(['tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId])->first();
if($existing){
$snapshot=json_decode($existing->snapshot,true,512,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['inventory_source_hash']??null)===$hash && (string)$existing->quantity_milli===(string)$facts['accepted_milli']
&& (int)$existing->stock_item_id===(int)$facts['stock_item_id'] && (int)$existing->warehouse_id===(int)$facts['warehouse_id'],409,'A origem já tem movimento incompatível ou histórico não reconciliado.');
return ['movement_id'=>(int)$existing->id,'source_hash'=>$hash,'replayed'=>true];
}
$lineSnapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
$receiptSnapshot=json_decode($facts['receipt_snapshot'],true,512,JSON_THROW_ON_ERROR);
$snapshot=[...$lineSnapshot,'receipt_id'=>(int)$facts['receipt_id'],'order_number'=>$receiptSnapshot['order']['number']??null,
'delivery_note'=>$facts['delivery_note'],'reference'=>$facts['delivery_note'],'inventory_source_hash'=>$hash];
$id=$this->db->table($this->schema.'.stock_movements')->insertGetId([
'tenant_id'=>$this->tenant,'goods_receipt_item_id'=>$lineId,'stock_item_id'=>$facts['stock_item_id'],'warehouse_id'=>$facts['warehouse_id'],
'moved_by'=>$this->actor,'quantity_milli'=>$facts['accepted_milli'],'movement_type'=>'receipt_in','moved_at'=>now(),
'equipment_id'=>$lineSnapshot['equipment_id']??null,'spare_part_id'=>$lineSnapshot['spare_part_id']??null,
'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),
]);
return ['movement_id'=>(int)$id,'source_hash'=>$hash,'replayed'=>false];
}
private function checkHost(): void
{
if($this->db->transactionLevel()<1)throw new \LogicException('Receipt movement requires the host atomic transaction');
($this->authorize)();
}
private function facts(int $lineId,bool $lock): array
{
$q=$this->db->table($this->schema.'.goods_receipt_items AS l')
->join($this->schema.'.goods_receipts AS r',fn($j)=>$j->on('r.id','=','l.goods_receipt_id')->on('r.tenant_id','=','l.tenant_id'))
->where('l.tenant_id',$this->tenant)->where('l.id',$lineId)
->select(['l.id','l.tenant_id','l.stock_item_id','l.received_milli','l.accepted_milli','l.rejected_milli','l.quality_status','l.notes',
'l.snapshot AS line_snapshot','r.id AS receipt_id','r.purchase_order_id','r.received_by','r.warehouse_id','r.delivery_note','r.received_on','r.confirmed_at','r.snapshot AS receipt_snapshot']);
if($lock)$q->lockForUpdate();
$row=$q->first();abort_unless($row,403,'Origem fora do âmbito autorizado.');$facts=(array)$row;
$snapshot=json_decode($facts['line_snapshot'],true,512,JSON_THROW_ON_ERROR);
abort_unless((int)$facts['received_by']===$this->actor,403,'A origem não pertence ao recebedor desta confirmação.');
abort_unless($facts['confirmed_at']!==null && ($snapshot['physical']??false)===true && (int)$facts['accepted_milli']>0
&& $facts['stock_item_id']!==null && $facts['warehouse_id']!==null,409,'A origem não comprova quantidade física aceite.');
return $facts;
}
private function hash(array $facts): string
{
// Parent status/number are intentionally absent: finalisation occurs later in the same transaction.
return hash('sha256',json_encode($facts,JSON_THROW_ON_ERROR));
}
}
@@ -0,0 +1,130 @@
<?php
namespace Tempo\SgcInvoiceClient;
use Closure;
use InvalidArgumentException;
use LogicException;
/** Prepared transport, no database, filesystem, automatic retry or automatic confirmation. */
final class InvoiceClient
{
private string $binding;
public function __construct(private string $client, private string $token, private string $context,
private array $sourceScope, private array $targetScope, private Closure $transport,
private string $origin = 'http://127.0.0.1:8085')
{
if ($origin !== 'http://127.0.0.1:8085' || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$client)
|| strlen($token)<32 || strlen($token)>512 || !preg_match('/^[a-f0-9]{64}$/D',$context)
|| array_keys($sourceScope)!==['tenant_id'] || !is_int($sourceScope['tenant_id']) || $sourceScope['tenant_id']<1
|| array_keys($targetScope)!==['tenant_id','company_id','unit_id']) throw new InvalidArgumentException('Invalid explicit connection.');
foreach ($targetScope as $value) if(!is_int($value)||$value<1)throw new InvalidArgumentException('Invalid target scope.');
$this->binding=self::hash([$origin,$client,$context,$sourceScope,$targetScope]);
}
public function __debugInfo(): array { return ['origin'=>$this->origin,'client'=>$this->client,'credentials'=>'[redacted]']; }
/** Caller supplies reviewed mappings and an unregistered source draft, never an existing SGC invoice. */
public function intent(array $mapping, array $draft, string $key, string $reason): array
{
self::keys($mapping,['id','version','source_scope','target_scope','order_id','lines','authority']);
self::keys($draft,['reference','version','state','document']);
if(!is_string($mapping['id']??null)||$mapping['id']===''||!is_int($mapping['version']??null)||$mapping['version']<1
||($mapping['source_scope']??null)!==$this->sourceScope||($mapping['target_scope']??null)!==$this->targetScope
||($mapping['authority']??null)!=='sgf_only'||!is_int($mapping['order_id']??null)||$mapping['order_id']<1
||!is_array($mapping['lines']??null)||($draft['state']??null)!=='unregistered_draft'
||!is_string($draft['reference']??null)||$draft['reference']===''||!is_string($draft['version']??null)||$draft['version']===''
||!preg_match('/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[1-8][a-fA-F0-9]{3}-[89aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/D',$key)
||strlen($reason)<10||strlen($reason)>2000)throw new InvalidArgumentException('Explicit draft authority/mapping required.');
$document=$draft['document']??[]; self::keys($document,['number','issued_on','subtotal','tax','total','items']);
foreach(['number','issued_on','subtotal','tax','total']as$field)if(!is_string($document[$field]??null)||$document[$field]==='')throw new InvalidArgumentException('Document field required.');
foreach(['subtotal','tax','total']as$field)if(!preg_match('/^\d{1,12}\.\d{2}$/D',$document[$field]))throw new InvalidArgumentException('Exact decimal strings required.');
if(!is_array($document['items']??null)||count($document['items'])<1||count($document['items'])>50)throw new InvalidArgumentException('Explicit lines required.');
$lines=[];$used=[];
foreach($document['items']as$item){
self::keys($item,['source_line_ref','quantity','unit','unit_price','tax_rate']);
$ref=$item['source_line_ref']??null;$id=is_string($ref)?($mapping['lines'][$ref]??null):null;
if(!is_int($id)||$id<1||in_array($id,$used,true))throw new InvalidArgumentException('Missing or repeated explicit line mapping.');
foreach(['quantity','unit','unit_price','tax_rate']as$field)if(!is_string($item[$field]??null)||$item[$field]==='')throw new InvalidArgumentException('Exact line fields required.');
$used[]=$id;unset($item['source_line_ref']);$lines[]=['purchase_order_item_id'=>$id]+$item;
}
$document['items']=$lines;
$intent=['schema'=>1,'binding'=>$this->binding,'mapping_hash'=>self::hash($mapping),'source_reference'=>$draft['reference'],
'source_version'=>$draft['version'],'source_hash'=>self::hash($draft),'operation_key'=>strtolower($key),
'order_id'=>$mapping['order_id'],'reason'=>$reason,'data'=>$document];
if(strlen(json_encode($intent,JSON_THROW_ON_ERROR))>65536)throw new InvalidArgumentException('Document too large.');
return $intent+['intent_hash'=>self::hash($intent)];
}
public static function mappingHash(array $mapping): string { return self::hash($mapping); }
public function preview(array $intent, string $currentSourceVersion, string $currentMappingHash): array
{
$this->verifyIntent($intent,$currentSourceVersion,$currentMappingHash);
$base=['intent'=>$intent,'phase'=>'draft'];
$context=$this->call('GET','/api/v1/integration/sgc/invoices/orders/'.$intent['order_id'],null,$intent['operation_key']);
if(($context['status']??0)!==200)return $this->failed($base,$context,false);
$view=$context['json']??[];
if(($view['order']['id']??null)!==$intent['order_id']||!preg_match('/^[a-f0-9]{64}$/D',$view['revision']??''))throw new LogicException('Unexpected order context.');
$r=$this->call('POST','/api/v1/integration/sgc/invoices',['phase'=>'preview','order_id'=>$intent['order_id'],
'revision'=>$view['revision'],'reason'=>$intent['reason'],'data'=>$intent['data']],$intent['operation_key']);
if(($r['status']??0)!==200)return $this->failed($base,$r,false);
if(isset($r['json']['invoice_id']))return $this->received($base,$r,true);
if(!is_string($r['json']['preview']??null)||!is_array($r['json']['impact']??null))throw new LogicException('Unexpected preview response.');
$state=['intent'=>$intent,'phase'=>'previewed','preview'=>$r['json']['preview'],'impact'=>$r['json']['impact']];
return $state+['approval_hash'=>self::hash($state)];
}
/** The human decision must refer to this exact impact, source version and intention. */
public function confirm(array $state, string $approvedHash, string $currentSourceVersion, string $currentMappingHash): array
{
$this->verifyIntent($state['intent']??[],$currentSourceVersion,$currentMappingHash);
$original=['intent'=>$state['intent'],'phase'=>'previewed','preview'=>$state['preview']??null,'impact'=>$state['impact']??null];
if(!in_array($state['phase']??null,['previewed','uncertain'],true)||!is_string($original['preview'])||!is_array($original['impact'])
||!is_string($state['approval_hash']??null)||!hash_equals(self::hash($original),$state['approval_hash'])
||!hash_equals($state['approval_hash'],$approvedHash))throw new LogicException('Individual approval of current impact required.');
$intent=$state['intent'];
$r=$this->call('POST','/api/v1/integration/sgc/invoices',['phase'=>'confirm','order_id'=>$intent['order_id'],'preview'=>$state['preview']],$intent['operation_key']);
if(!in_array($r['status']??0,[200,201],true))return $this->failed($state,$r,true);
return $this->received($state,$r,($r['status']??0)===200);
}
private function received(array $state,array $r,bool $replay):array
{
$receipt=$r['json']??[];
if(!is_int($receipt['invoice_id']??null)||$receipt['invoice_id']<1||($receipt['order_id']??null)!==$state['intent']['order_id']
||($receipt['status']??null)!=='pending'||($receipt['replayed']??null)!==$replay) {
// A malformed successful response may follow a committed write. Keep its original key/cut.
return array_replace($state,['phase'=>'uncertain','http_status'=>$r['status'],'error'=>'UNVERIFIED_RECEIPT']);
}
return array_replace($state,['phase'=>'received_pending','receipt'=>$receipt,'http_status'=>$r['status']]);
}
private function verifyIntent(array $intent,string $sourceVersion,string $mappingHash):void
{
$hash=$intent['intent_hash']??null;unset($intent['intent_hash']);
if(!is_string($hash)||!hash_equals(self::hash($intent),$hash)||($intent['binding']??null)!==$this->binding
||($intent['source_version']??null)!==$sourceVersion||($intent['mapping_hash']??null)!==$mappingHash)throw new LogicException('Source, mapping or connection changed; confer again.');
}
private function call(string $method,string $path,?array $body,string $key):array
{
try{return ($this->transport)($method,$this->origin.$path,['Accept'=>'application/json','Authorization'=>'Bearer '.$this->token,
'X-SGC-Client'=>$this->client,'X-SGC-Context'=>$this->context,'X-Idempotency-Key'=>$key],$body);}
catch(\Throwable){return ['status'=>0,'json'=>null];} // Never expose transport messages or credentials.
}
private function failed(array $state,array $response,bool $mayHaveCommitted):array
{
$status=$response['status']??0;
$phase=in_array($status,[401,403],true)?'blocked':($status===409?'review_required':
($mayHaveCommitted&&($status===0||$status>=500||($status>=200&&$status<400))?'uncertain':'not_sent'));
return array_replace($state,['phase'=>$phase,'http_status'=>$status,'error'=>'DELIVERY_NOT_CONFIRMED']);
}
private static function keys(array $value,array $allowed):void
{if(array_diff(array_keys($value),$allowed))throw new InvalidArgumentException('Unknown fields.');}
private static function hash(mixed $value):string
{
$normalise=function($v)use(&$normalise){if(!is_array($v))return $v;if(!array_is_list($v))ksort($v,SORT_STRING);foreach($v as&$item)$item=$normalise($item);return $v;};
return hash('sha256',json_encode($normalise($value),JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE));
}
}