commit14
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
final class Controller
|
||||
{
|
||||
public function show(Request $request,int $id){return response()->json((new Workspace($request->user()))->read($id))->header('Cache-Control','private, no-store');}
|
||||
public function command(Request $request,int $id,string $action){
|
||||
try{return response()->json((new Workspace($request->user()))->command($id,$action,$request->all()));}
|
||||
catch(RequisitionFailure $e){return response()->json(['message'=>$e->getMessage()],$e->status);}
|
||||
catch(\InvalidArgumentException $e){return response()->json(['message'=>$e->getMessage()],422);}
|
||||
}
|
||||
public function page(Request $request,int $id){
|
||||
(new Workspace($request->user()))->read($id);
|
||||
$contract=['id'=>$id,'csrf'=>csrf_token(),'context'=>$request->session()->get('company_context.token')];
|
||||
ob_start();require __DIR__.'/screen.php';return response(ob_get_clean())->header('Cache-Control','private, no-store');
|
||||
}
|
||||
public function script(){return response(file_get_contents(__DIR__.'/screen.mjs'))->header('Content-Type','text/javascript');}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Tempo\Purchasing\Delivery\Installation;
|
||||
|
||||
final class Schema
|
||||
{
|
||||
public const RIGHTS=['purchasing_sourcing.prepare','purchasing_sourcing.evaluate','purchasing_sourcing.propose','purchasing_sourcing.confirm'];
|
||||
public const TABLES=['ss_suppliers','ss_processes','ss_events','ss_commands'];
|
||||
public static function hash():string{return hash_file('sha256',__FILE__);}
|
||||
public static function signature(ConnectionInterface $db):string{return Installation::signature($db,[...Installation::tables(),...self::TABLES]);}
|
||||
public static function compatible(ConnectionInterface $db):bool{
|
||||
$row=$db->table('sgc_installations')->where('module','sourcing')->first();
|
||||
return $row&&$row->revision===1&&hash_equals($row->migration_sha256,self::hash())&&hash_equals($row->schema_signature,self::signature($db));
|
||||
}
|
||||
public static function migrate(ConnectionInterface $db,string $backupHash,string $operator):array{
|
||||
if($db->getDriverName()!=='pgsql'||!preg_match('/^[a-f0-9]{64}$/D',$backupHash)||trim($operator)==='')throw new \RuntimeException('Backup e operador obrigatórios.');
|
||||
return $db->transaction(function()use($db,$backupHash,$operator){
|
||||
$db->select("select pg_advisory_xact_lock(hashtext('tempo.sourcing.install.v1'))");
|
||||
if($db->table('sgc_installations')->where('module','sourcing')->exists()){
|
||||
if(!self::compatible($db))throw new \RuntimeException('Schema de sourcing divergente.');
|
||||
return ['prepared'=>true,'replayed'=>true];
|
||||
}
|
||||
$old=$db->table('sgc_installations')->where('module','internal-requisitions')->first();
|
||||
if(!$old||$old->revision!==2||!hash_equals($old->schema_signature,Installation::signature($db,Installation::tables())))throw new \RuntimeException('Conferir schema anterior antes de ampliar catálogo.');
|
||||
$schema=$db->getSchemaBuilder();
|
||||
foreach(self::TABLES as $table){
|
||||
if($schema->hasTable($table))throw new \RuntimeException('Schema parcial: não sobrescrever.');
|
||||
$schema->create($table,function(Blueprint $t)use($table){
|
||||
$t->id();foreach(['tenant_id','company_id','unit_id'] as $key)$t->unsignedBigInteger($key);
|
||||
if($table==='ss_suppliers'){$t->string('reference');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','reference']);}
|
||||
elseif($table==='ss_processes'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id']);}
|
||||
elseif($table==='ss_events'){$t->unsignedBigInteger('request_id');$t->unsignedInteger('version');$t->unsignedBigInteger('actor_id');$t->string('action');$t->jsonb('payload');$t->unique(['tenant_id','company_id','unit_id','request_id','version']);}
|
||||
else{$t->unsignedBigInteger('actor_id');$t->uuid('operation_key');$t->string('request_hash',64);$t->jsonb('result');$t->unique(['tenant_id','company_id','unit_id','actor_id','operation_key']);}
|
||||
$t->timestampTz('created_at');
|
||||
});
|
||||
$db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)");
|
||||
$db->statement("ALTER TABLE $table ADD UNIQUE(tenant_id,company_id,unit_id,id)");
|
||||
}
|
||||
foreach(['ss_processes','ss_events'] as $table)$db->statement("ALTER TABLE $table ADD FOREIGN KEY(tenant_id,company_id,unit_id,request_id) REFERENCES ir_requests(tenant_id,company_id,unit_id,id)");
|
||||
foreach(['ss_events','ss_commands'] as $table){
|
||||
$db->statement("ALTER TABLE $table ADD FOREIGN KEY(actor_id) REFERENCES users(id)");
|
||||
$db->unprepared("CREATE FUNCTION {$table}_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve sourcing history'; END; \$\$; CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION {$table}_immutable();");
|
||||
}
|
||||
$db->statement("ALTER TABLE ss_processes ADD CHECK(payload->'confirmed' IS NULL OR payload->'confirmed'='null'::jsonb OR (payload->'confirmed'->>'confirmed_by')::bigint<>(payload->'confirmed'->'proposal'->>'proposed_by')::bigint)");
|
||||
$db->unprepared(<<<'SQL'
|
||||
CREATE FUNCTION ss_process_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE requester bigint;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Preserve sourcing origin'; END IF;
|
||||
IF (NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.request_id) IS DISTINCT FROM (OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.request_id) OR NEW.payload->'origin' IS DISTINCT FROM OLD.payload->'origin' THEN RAISE EXCEPTION 'Immutable sourcing origin'; END IF;
|
||||
IF NEW.version<>OLD.version+1 THEN RAISE EXCEPTION 'Sourcing version must advance once'; END IF;
|
||||
IF OLD.payload->'confirmed' IS NOT NULL AND OLD.payload->'confirmed'<>'null'::jsonb THEN RAISE EXCEPTION 'Confirmed award immutable'; END IF;
|
||||
SELECT requester_id INTO requester FROM ir_requests WHERE id=NEW.request_id;
|
||||
IF NEW.payload->'confirmed' IS NOT NULL AND NEW.payload->'confirmed'<>'null'::jsonb AND (requester=(NEW.payload->'confirmed'->>'confirmed_by')::bigint OR requester=(NEW.payload->'confirmed'->'proposal'->>'proposed_by')::bigint) THEN RAISE EXCEPTION 'Requester cannot award'; END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ss_process_guard BEFORE UPDATE OR DELETE ON ss_processes FOR EACH ROW EXECUTE FUNCTION ss_process_guard();
|
||||
SQL);
|
||||
$c=$db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
|
||||
if(!preg_match('/^CHECK\s*\((.*)\)$/s',$c->definition,$match))throw new \RuntimeException('Catálogo desconhecido.');
|
||||
$rights=implode(',',array_map(fn($r)=>$db->getPdo()->quote($r).'::text',self::RIGHTS));
|
||||
$db->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
$db->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (({$match[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($rights)))");
|
||||
$db->table('sgc_installations')->insert(['module'=>'sourcing','revision'=>1,'schema_signature'=>self::signature($db),'migration_sha256'=>self::hash(),'backup_sha256'=>$backupHash,'operator'=>$operator,'database_operator'=>$db->selectOne('select current_user as role')->role,'prepared_at'=>now()]);
|
||||
return ['prepared'=>true,'replayed'=>false,'tables'=>self::TABLES,'access_granted'=>false];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
namespace Tempo\Purchasing\SgfSourcing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tempo\Purchasing\SgfCandidate\Port;
|
||||
use Tempo\Purchasing\Sourcing\Sourcing;
|
||||
use Tempo\Purchasing\Internal\RequisitionFailure;
|
||||
|
||||
final class Workspace
|
||||
{
|
||||
private Port $port;
|
||||
public function __construct(private User $actor){$this->port=new Port($actor);}
|
||||
private function q(string $table){return DB::table('ss_'.$table)->where($this->port->scope());}
|
||||
private function auth(string $action):void{
|
||||
$this->port->authorize('view',null);
|
||||
if($action==='view')return;
|
||||
$right=match($action){'select','quote'=>'prepare','evaluate'=>'evaluate','propose'=>'propose','confirm'=>'confirm',default=>abort(404)};
|
||||
$s=$this->port->scope();app(Permissions::class)->require($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);
|
||||
}
|
||||
private function policy(array $state=[]):array{return $state['policy']??json_decode(file_get_contents(__DIR__.'/comparison-defaults.json'),true,32,JSON_THROW_ON_ERROR);}
|
||||
private function state(int $id):array{
|
||||
$row=$this->q('processes')->where('request_id',$id)->lockForUpdate()->first();
|
||||
$state=$row?json_decode($row->payload,true,64,JSON_THROW_ON_ERROR):['version'=>0,'suppliers'=>[],'quotes'=>[],'proposals'=>[],'confirmed'=>null];
|
||||
$s=$this->port->scope();
|
||||
foreach($state['quotes'] as $key=>&$quote){
|
||||
$supplier=$this->q('suppliers')->where('id',$key)->first();
|
||||
$payload=$supplier?json_decode($supplier->payload,true):[];
|
||||
$quote['supplier_eligible']=($payload['active']??false)&&!($payload['blocked']??true)&&($payload['review_until']??'')>=date('Y-m-d');
|
||||
foreach($quote['evaluations'] as &$evaluation){
|
||||
$id=$evaluation['evaluator_id'];
|
||||
$evaluation['eligible']=DB::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists()
|
||||
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'unit.view',$s['unit_id'])
|
||||
&&app(Permissions::class)->has($id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.evaluate',$s['unit_id']);
|
||||
}unset($evaluation);
|
||||
}unset($quote);
|
||||
return $state;
|
||||
}
|
||||
public function read(int $id):array{return $this->port->atomic(function()use($id){
|
||||
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
|
||||
$this->auth('view');$r=$this->port->load($id);$this->port->authorize('view',$r);$s=$this->state($id);
|
||||
return ['request'=>$r,'state'=>$s,'policy'=>$this->policy($s),'comparison'=>(new Sourcing)->comparison($s,$this->policy($s),date('Y-m-d')),'suppliers'=>$this->q('suppliers')->orderBy('id')->get(),'history'=>$this->q('events')->where('request_id',$id)->orderBy('version')->get(),'rights'=>$this->rights()];
|
||||
});}
|
||||
private function rights():array{
|
||||
$s=$this->port->scope();$result=[];foreach(['prepare','evaluate','propose','confirm'] as $right)$result[$right]=app(Permissions::class)->has($this->actor->id,$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$right,$s['unit_id']);return $result;
|
||||
}
|
||||
public function command(int $id,string $action,array $input):array{return $this->port->atomic(function()use($id,$action,$input){
|
||||
abort_unless(config('purchasing-sourcing.enabled')===true&&Schema::compatible(DB::connection()),503,'Sourcing não preparado.');
|
||||
$this->auth($action);$r=$this->port->load($id);$this->port->authorize('view',$r);
|
||||
$meta=Validator::make($input,['operation_key'=>'required|uuid','version'=>'required|integer|min:0'])->validate();
|
||||
$meta['version']=(int)$meta['version'];
|
||||
$hash=hash('sha256',json_encode([$id,$action,$input],JSON_THROW_ON_ERROR));
|
||||
$old=$this->q('commands')->where('actor_id',$this->actor->id)->where('operation_key',$meta['operation_key'])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com outros dados.');return json_decode($old->result,true);}
|
||||
$state=$this->state($id);abort_unless($state['version']===$meta['version'],409,'Versão do processo alterada.');
|
||||
$origin=['request_id'=>$r['id'],'origin_key'=>$r['origin_key'],'request_version'=>$r['version'],'budget_origin_id'=>$r['budget_origin_id'],'snapshot_hash'=>hash('sha256',json_encode([$r['data'],$r['approval_snapshot'],$r['reservation']],JSON_THROW_ON_ERROR))];
|
||||
if($state['version']===0){$state['origin']=$origin;$state['policy']=$this->policy();}else abort_unless(($state['origin']??null)==$origin,409,'A origem mudou; reconcilie o processo sem criar outra reserva.');
|
||||
$rules=match($action){
|
||||
'select'=>['supplier_id'=>'required|integer|min:1'],
|
||||
'quote'=>['supplier_id'=>'required|integer|min:1','reference'=>'required|string|max:100','currency'=>'required|in:MZN','valid_until'=>'required|date_format:Y-m-d','delivery_days'=>'required|integer|between:1,3650','payment_terms'=>'required|string|min:3|max:500','items'=>'required|array|min:1|max:100','items.*.line'=>'required|integer|min:0','items.*.unit_price'=>'required|string|regex:/^\d{1,10}(\.\d{1,2})?$/','items.*.discount_type'=>'sometimes|in:none,percent,amount','items.*.discount_value'=>'sometimes|string|regex:/^\d{1,10}(\.\d{1,2})?$/'],
|
||||
'evaluate'=>['supplier_id'=>'required|integer|min:1','quote_revision'=>'required|integer|min:1','quality'=>'required|numeric|between:0,10','history'=>'required|numeric|between:0,10','payment'=>'required|numeric|between:0,10','no_conflict'=>'accepted','reason'=>'required|string|min:10|max:2000'],
|
||||
'propose'=>['supplier_id'=>'required|integer|min:1','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'],
|
||||
'confirm'=>['proposal_index'=>'required|integer|min:0','comparison_hash'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000'],default=>abort(404)};
|
||||
$data=Validator::make($input,$rules)->validate();
|
||||
foreach(['supplier_id','quote_revision','proposal_index','delivery_days'] as $field)if(isset($data[$field]))$data[$field]=(int)$data[$field];
|
||||
if(isset($data['items']))foreach($data['items'] as &$line)$line['line']=(int)$line['line'];unset($line);
|
||||
if($action==='select'){
|
||||
$supplier=$this->q('suppliers')->where('id',$data['supplier_id'])->lockForUpdate()->first();abort_unless($supplier,404);
|
||||
$payload=json_decode($supplier->payload,true);
|
||||
$data['supplier']=['id'=>$supplier->id,'reference'=>$supplier->reference,'snapshot'=>$payload,'eligible'=>$payload['active']&&!$payload['blocked']&&$payload['review_until']>=date('Y-m-d')];
|
||||
}
|
||||
if(in_array($action,['propose','confirm'],true)){
|
||||
$supplier=$action==='propose'?$data['supplier_id']:(end($state['proposals'])['supplier_id']??null);
|
||||
$quote=$state['quotes'][$supplier]??null;abort_unless($quote,422,'Cotação em falta.');
|
||||
// Do not invent an automatic budget revision or create another reserve.
|
||||
$budget=DB::table('cb_budgets')->where($this->port->scope())->where('id',$r['reservation']['budget_id']??0)->lockForUpdate()->first();
|
||||
$entries=DB::table('cb_budget_entries')->where($this->port->scope())->where('requisition_id',$r['budget_origin_id']);
|
||||
$basis=$budget?($budget->includes_tax?$quote['total_minor']:$quote['subtotal_minor']):null;
|
||||
abort_unless($budget&&$budget->approved_by&&$basis===(int)(clone $entries)->sum('reserved_minor')&&(int)(clone $entries)->sum('committed_minor')===0,409,'A alteração do valor exige adaptação orçamental ainda não integrada neste percurso. Nenhum movimento foi criado.');
|
||||
$rules=$r['approval_snapshot'];$approvalAmount=$rules['approval_includes_tax']?$quote['total_minor']:$quote['subtotal_minor'];
|
||||
abort_unless($approvalAmount<=$rules['first_limit_minor'],409,'A cadeia de adjudicação acima do primeiro limite ainda requer integração dos níveis existentes de Compras.');
|
||||
if($action==='confirm'){
|
||||
$members=$this->port->setup()['members'];
|
||||
abort_unless(count(array_filter($members,fn($member)=>$member['user_id']===$this->actor->id&&$member['department_ref']===$r['department_ref']))===1,403,'Confirmação exige atribuição ao departamento da origem.');
|
||||
}
|
||||
}
|
||||
$next=(new Sourcing)->transition($state,$r,$this->actor->id,$action,$data,$this->policy($state),date('Y-m-d'));
|
||||
$next['version']=$state['version']+1;$now=now();$scope=$this->port->scope();
|
||||
$values=['version'=>$next['version'],'payload'=>json_encode($next,JSON_THROW_ON_ERROR)];
|
||||
if($state['version']===0)$this->q('processes')->insert($scope+['request_id'=>$id,...$values,'created_at'=>$now]);else $this->q('processes')->where('request_id',$id)->update($values);
|
||||
$this->q('events')->insert($scope+['request_id'=>$id,'version'=>$next['version'],'actor_id'=>$this->actor->id,'action'=>$action,'payload'=>json_encode(['before_version'=>$state['version'],'input'=>$data,'state'=>$next],JSON_THROW_ON_ERROR),'created_at'=>$now]);
|
||||
$failFile=storage_path('framework/sourcing-failure.fixture');clearstatcache(true,$failFile);
|
||||
if(app()->environment('testing')&&preg_match('/^sgf_sourcing_test_[a-f0-9]{12}$/D',DB::connection()->getDatabaseName())&&is_file($failFile))throw new \RuntimeException('Injected sourcing persistence failure');
|
||||
$result=['request_id'=>$id,'version'=>$next['version'],'confirmed'=>$next['confirmed']!==null];
|
||||
$this->q('commands')->insert($scope+['actor_id'=>$this->actor->id,'operation_key'=>$meta['operation_key'],'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>$now]);
|
||||
return $result;
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
foreach(['QuotationPricing','ProposalScoring'] as $class)require_once dirname(__DIR__,3).'/packages/purchasing/src/'.$class.'.php';
|
||||
require_once dirname(__DIR__,3).'/packages/purchasing/src/Sourcing.php';
|
||||
foreach(['Schema','Workspace','Controller'] as $class)require_once __DIR__.'/'.$class.'.php';
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use Tempo\Purchasing\Delivery\RuntimeContext;
|
||||
use Tempo\Purchasing\SgfSourcing\Controller;
|
||||
Route::middleware(['web','auth',RequireValidSession::class,RequireCompanyContext::class,RuntimeContext::class])->group(function(){
|
||||
Route::get('/purchasing/sourcing/screen.mjs',[Controller::class,'script']);
|
||||
Route::get('/purchasing/sourcing/{id}',[Controller::class,'page'])->whereNumber('id');
|
||||
Route::get('/api/sourcing/{id}',[Controller::class,'show'])->whereNumber('id');
|
||||
Route::post('/api/sourcing/{id}/{action}',[Controller::class,'command'])->whereNumber('id')->whereIn('action',['select','quote','evaluate','propose','confirm']);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const c=JSON.parse(document.querySelector('#contract').textContent);let data;
|
||||
const el=id=>document.getElementById(id),money=v=>(v/100).toLocaleString('pt-MZ',{minimumFractionDigits:2,maximumFractionDigits:2})+' MZN';
|
||||
async function api(action,input){const r=await fetch('/api/sourcing/'+c.id+(action?'/'+action:''),{method:action?'POST':'GET',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':c.csrf,'X-SGF-Context':c.context,'X-Purchasing-Context':'procurement.internal'},...(action?{body:JSON.stringify({...input,version:data.state.version,operation_key:crypto.randomUUID()})}:{})});const d=await r.json();if(!r.ok)throw Error(d.message??JSON.stringify(d.errors));return d;}
|
||||
function options(select,rows){select.replaceChildren(...rows.map(([id,text])=>{const o=document.createElement('option');o.value=id;o.textContent=text;return o;}));}
|
||||
function textNode(tag,text){const node=document.createElement(tag);node.textContent=text;return node;}
|
||||
async function refresh(){data=await api();el('origin').textContent=`Requisição ${data.request.id}, versão ${data.request.version}, ${money(data.request.total_minor)}; processo versão ${data.state.version}. Mínimos: ${data.policy.minimum_proposals} fornecedores e ${data.policy.minimum_evaluators} avaliadores.`;
|
||||
const table=document.createElement('table');table.append(textNode('caption',data.comparison.ready?'Comparação completa':'Comparação incompleta: recolher propostas e avaliações elegíveis'));const head=document.createElement('tr');for(const s of ['Fornecedor','Revisão','Valor','Entrega','Pagamento','Avaliações','Pontuação'])head.append(textNode('th',s));table.append(head);
|
||||
for(const q of data.comparison.rows){const row=document.createElement('tr');for(const s of [data.state.suppliers[q.supplier_id]?.snapshot?.name??q.supplier_id,q.revision,money(q.total_minor),q.delivery_days,q.payment_terms,q.evaluation_count,data.comparison.scores[q.id]===undefined?'—':(data.comparison.scores[q.id]/100).toFixed(2)])row.append(textNode('td',String(s)));table.append(row);}el('comparison').replaceChildren(table);
|
||||
const supplierRows=data.suppliers.map(s=>[s.id,JSON.parse(typeof s.payload==='string'?s.payload:JSON.stringify(s.payload)).name]);options(el('select').elements.supplier_id,supplierRows);
|
||||
const selected=Object.entries(data.state.suppliers).map(([id,s])=>[id,s.snapshot.name]);options(el('quote').elements.supplier_id,selected);
|
||||
const quotes=data.comparison.rows.map(q=>[q.supplier_id,`${data.state.suppliers[q.supplier_id].snapshot.name} · revisão ${q.revision}`]);options(el('evaluation').elements.supplier_id,quotes);options(el('proposal').elements.supplier_id,quotes);
|
||||
el('lines').replaceChildren(...data.request.data.items.map((line,i)=>{const label=document.createElement('label');label.textContent=`${line.description} · ${line.quantity} · imposto ${line.tax_rate}% `;const input=document.createElement('input');input.name='price_'+i;input.required=true;input.inputMode='decimal';label.append(input);return label;}));
|
||||
for(const right of ['prepare','evaluate','propose','confirm'])el(right).hidden=!data.rights[right]||data.state.confirmed!==null;
|
||||
el('proposals').textContent=JSON.stringify(data.state.proposals,null,2);el('history').replaceChildren(...data.history.map(e=>textNode('p',`Versão ${e.version} · ${e.action} · identidade ${e.actor_id} · ${e.created_at}`)));
|
||||
if(data.state.confirmed)el('message').textContent='Adjudicação confirmada. Processo fechado.';
|
||||
}
|
||||
async function submit(form,action,make){form.addEventListener('submit',async e=>{e.preventDefault();try{await api(action,make(new FormData(form)));el('message').textContent='Registado.';await refresh();}catch(error){el('message').textContent=error.message;}});}
|
||||
submit(el('select'),'select',f=>({supplier_id:Number(f.get('supplier_id'))}));
|
||||
submit(el('quote'),'quote',f=>({supplier_id:Number(f.get('supplier_id')),reference:f.get('reference'),currency:'MZN',valid_until:f.get('valid_until'),delivery_days:Number(f.get('delivery_days')),payment_terms:f.get('payment_terms'),items:data.request.data.items.map((_,i)=>({line:i,unit_price:f.get('price_'+i)}))}));
|
||||
submit(el('evaluation'),'evaluate',f=>({supplier_id:Number(f.get('supplier_id')),quote_revision:data.state.quotes[f.get('supplier_id')].revision,quality:Number(f.get('quality')),history:Number(f.get('history')),payment:Number(f.get('payment')),no_conflict:f.get('no_conflict')==='on',reason:f.get('reason')}));
|
||||
submit(el('proposal'),'propose',f=>({supplier_id:Number(f.get('supplier_id')),comparison_hash:data.comparison.fingerprint,reason:f.get('reason'),delivery_date:f.get('delivery_date'),delivery_location:f.get('delivery_location')}));
|
||||
submit(el('confirmation'),'confirm',f=>({proposal_index:data.state.proposals.length-1,comparison_hash:data.comparison.fingerprint,reason:f.get('reason')}));
|
||||
el('refresh').onclick=()=>refresh().catch(e=>el('message').textContent=e.message);refresh().catch(e=>el('message').textContent=e.message);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Consulta de fornecedores e adjudicação</title>
|
||||
<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto;padding:1rem}label{display:block;margin:.7rem 0}input,select,textarea,button{font:inherit;padding:.5rem}textarea{width:95%;min-height:4rem}table{border-collapse:collapse;width:100%}td,th{border:1px solid #aaa;padding:.5rem}section{margin:2rem 0}#message{white-space:pre-wrap}</style>
|
||||
<a href="/purchasing/internal-requisitions">Requisições</a><h1>Consulta de fornecedores e adjudicação</h1><p>Compras internas · contexto activo da empresa e unidade seleccionadas. Registo local de cotações; não envia pedidos a fornecedores.</p><p id="message" role="status"></p><button id="refresh">Actualizar</button>
|
||||
<section><h2>Origem e comparação</h2><div id="origin"></div><div id="comparison"></div></section>
|
||||
<section id="prepare" hidden><h2>Seleccionar fornecedor</h2><form id="select"><select name="supplier_id" required></select><button>Seleccionar</button></form>
|
||||
<h2>Registar ou rever cotação</h2><form id="quote"><label>Fornecedor seleccionado <select name="supplier_id" required></select></label><label>Referência <input name="reference" required maxlength="100"></label><label>Validade <input name="valid_until" type="date" required></label><label>Prazo de entrega em dias <input name="delivery_days" type="number" min="1" required></label><label>Condições de pagamento <textarea name="payment_terms" required></textarea></label><div id="lines"></div><p>Preços unitários em MZN. Quantidades e impostos preservam as linhas da requisição.</p><button>Guardar cotação</button></form></section>
|
||||
<section id="evaluate" hidden><h2>Avaliação da versão da cotação</h2><form id="evaluation"><select name="supplier_id" required></select><label>Qualidade (0–10)<input name="quality" type="number" min="0" max="10" step=".01" required></label><label>Histórico (0–10)<input name="history" type="number" min="0" max="10" step=".01" required></label><label>Pagamento (0–10)<input name="payment" type="number" min="0" max="10" step=".01" required></label><label><input name="no_conflict" type="checkbox" required> Declaro ausência de conflito de interesses</label><label>Fundamento <textarea name="reason" minlength="10" required></textarea></label><button>Guardar avaliação</button></form></section>
|
||||
<section id="propose" hidden><h2>Propor adjudicação</h2><form id="proposal"><select name="supplier_id" required></select><label>Justificação <textarea name="reason" minlength="10" required></textarea></label><label>Data de entrega prevista <input name="delivery_date" type="date" required></label><label>Local de entrega previsto <input name="delivery_location" minlength="3" required></label><button>Propor com a comparação apresentada</button></form></section>
|
||||
<section id="confirm" hidden><h2>Confirmação independente</h2><form id="confirmation"><label>Fundamento <textarea name="reason" minlength="10" required></textarea></label><button>Confirmar a proposta apresentada</button></form></section>
|
||||
<section><h2>Propostas de adjudicação preservadas</h2><pre id="proposals"></pre><h2>Histórico por versão</h2><div id="history"></div></section>
|
||||
<script id="contract" type="application/json"><?=json_encode($contract,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR)?></script><script type="module" src="/purchasing/sourcing/screen.mjs"></script></html>
|
||||
Reference in New Issue
Block a user