This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Identity\Http;
use App\Shared\Context\{CompanyContext,ModuleNavigation,ModuleRecords};
use App\Modules\Identity\Services\Permissions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModuleRecordsController
{
public function index(Request $r,CompanyContext $context,Permissions $permissions)
{
$s=$context->identifiers();$out=[];
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
foreach(ModuleRecords::AREAS as $area){
$unit=str_ends_with($area,'_drafts')?$context->unitId():null;
if(str_ends_with($area,'_drafts')&&!$unit)continue;
if(!$permissions->has($context->userId(),$s['tenant_id'],$s['company_id'],"$area.view",$unit))continue;
$rows=DB::table('a_'.$area)->where($s)->where('unit_id',$unit)->orderBy('id')->get(['id','name','version']);
foreach($rows as $row){
$row->modules=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
->whereRaw("details::jsonb->>'area' = ?",[$area])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$row->id])
->orderByDesc('id')->get(['details','action'])->unique(fn($a)=>json_decode($a->details,true)['module_id'])
->filter(fn($a)=>$a->action==='module.record.associated')->map(fn($a)=>json_decode($a->details,true)['module_id'])->values()->all();
}
$out[$area]=$rows;
}
return response()->json(['records'=>$out,'modules'=>app(ModuleNavigation::class)->available($context),
'notice'=>'Registos sem associação explícita permanecem aqui visíveis pelos direitos originais. Associar não altera o registo, a origem ou as permissões.'])->header('Cache-Control','no-store');
}
public function associate(Request $r,CompanyContext $context,Permissions $permissions)
{
$d=$r->validate(['area'=>'required|in:'.implode(',',ModuleRecords::AREAS),'id'=>'required|integer|min:1','version'=>'required|integer|min:1','module_id'=>'required|string','reason'=>'required|string|min:3|max:500','operation'=>'sometimes|in:associate,remove']);
abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);
$s=$context->identifiers();$unit=str_ends_with($d['area'],'_drafts')?$context->unitId():null;
abort_if(str_ends_with($d['area'],'_drafts')&&!$unit,403);
DB::transaction(function()use($d,$s,$unit,$context,$permissions){
DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.view',$unit);
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.edit',$unit);
abort_unless(in_array($d['module_id'],array_column(app(ModuleNavigation::class)->available($context),'id'),true),403);
$row=DB::table('a_'.$d['area'])->where($s)->where('unit_id',$unit)->where('id',$d['id'])->first(['id','version']);
abort_unless($row,403);
abort_unless((int)$row->version===(int)$d['version'],409,'O registo mudou. Reabra a consulta antes de decidir a associação.');
$previous=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
->whereRaw("details::jsonb->>'area' = ?",[$d['area']])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$d['id']])->whereRaw("details::jsonb->>'module_id' = ?",[$d['module_id']])->orderByDesc('id')->value('action');
$remove=($d['operation']??'associate')==='remove';
if(($remove&&$previous==='module.record.associated')||(!$remove&&$previous!=='module.record.associated'))
ModuleRecords::associate($d['area'],$d['id'],$context->userId(),$s+['unit_id'=>$unit],$d['module_id'],$d['reason'],$remove,(int)$row->version);
});
return response()->json(['ok'=>true]);
}
}
@@ -25,7 +25,13 @@ final class RequireCompanyContext
abort_unless(is_string($token) && hash_equals($selection['token'] ?? '', $token), 409, 'O contexto mudou. Reabra o formulário antes de guardar.');
}
return $next($request);
app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context);
$response = $next($request);
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments'], true) && $response->isSuccessful()) {
$bar = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>';
$response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1));
}
return $response;
} finally {
$this->context->clear();
}
+1 -1
View File
@@ -9,7 +9,7 @@ final class Audit
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
{
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id']));
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id']));
DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
@@ -43,7 +43,15 @@ final class IdentityWorkflow
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id)
->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all();
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
'token' => $selected['token'], 'grants' => $grants];
'token' => $selected['token'], 'grants' => $grants,
'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
'module_id' => $selected['module_id'] ?? null];
if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) {
$result['context']['module_id'] = null;
$result['context']['token'] = bin2hex(random_bytes(24));
$request->session()->put('company_context', array_replace($selected, ['module_id'=>null, 'token'=>$result['context']['token']]));
$result['notice'] = 'O acesso ao módulo mudou. Seleccione um módulo autorizado.';
}
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
if ($exception->getStatusCode() !== 403) { throw $exception; }
$request->session()->forget('company_context');
@@ -54,14 +62,26 @@ final class IdentityWorkflow
public function select(Request $request): array
{
$data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1']);
$data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1', 'module_id'=>'nullable|string|max:40']);
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
// Selection is explicit, never silently carries the old unit into another company.
$this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
$request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]);
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
return $this->snapshot($request);
$module = $data['module_id'] ?? null;
abort_if($module !== null && !in_array($module, array_column(app(\App\Shared\Context\ModuleNavigation::class)->available($this->context), 'id'), true), 403, 'Módulo não autorizado ou não configurado.');
$previous = $request->session()->get('company_context.module_id');
$oldSelection=$request->session()->get('company_context');
try {
return DB::transaction(function()use($request,$data,$unit,$module,$previous){
$request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
'unit_id' => $unit, 'module_id'=>$module, 'token' => bin2hex(random_bytes(24))]);
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit, details:['module_id'=>$module, 'previous_module_id'=>$previous]);
return $this->snapshot($request);
});
} catch (\Throwable $failure) {
if($oldSelection===null)$request->session()->forget('company_context');
else $request->session()->put('company_context',$oldSelection);
throw $failure;
}
}
public function renameCompany(Request $request, int $company): void
+4 -4
View File
@@ -36,7 +36,7 @@ final class CatalogueAccounting
public function listing(int $actor): array
{
$scope=$this->scope($actor); $rows=$this->query($scope)->orderBy('id')->get()->map(fn($r)=>$this->decode($r))->all();
$services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? DB::table('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
$services=$this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'services.view') ? \App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->orderBy('name')->get(['id','version','name'])->all() : [];
return ['notice'=>self::NOTICE,'reference'=>self::REFERENCE,'proposals'=>$rows,'services'=>$services];
}
private function command(int $actor,string $action,array $request,string $key,callable $write): array
@@ -73,7 +73,7 @@ final class CatalogueAccounting
if(isset($data['service_id'])) {
$data['service_id']=(int)$data['service_id'];
$this->permissions->require($this->context->userId(),$scope['tenant_id'],$scope['company_id'],'services.view');
abort_unless(DB::table('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$data['service_id'])->exists(),403,'Serviço fora da empresa.');
}
if($kind==='account') {
$data['movable']=(bool)$data['movable'];
@@ -166,7 +166,7 @@ final class CatalogueAccounting
}
private function resolveInternal(int $service,string $date,array $scope): array
{
$s=DB::table('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
$s=\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->first(); abort_unless($s,403,'Serviço fora da empresa.');
$base=['service'=>['id'=>$service,'version'=>(int)$s->version,'name'=>$s->name],'reference_date'=>$date,'notice'=>self::NOTICE];
$override=$this->select($scope,'service_override','service:'.$service,$date);
$link=$this->select($scope,'service_link','service:'.$service,$date);
@@ -181,7 +181,7 @@ final class CatalogueAccounting
public function resolve(int $service,?string $date,int $actor): array
{
$scope=$this->scope($actor); $this->permissions->require($actor,$scope['tenant_id'],$scope['company_id'],'services.view');
abort_unless(DB::table('a_services')->where($scope)->where('id',$service)->exists(),403);
abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where($scope)->where('id',$service)->exists(),403);
if(!$date)return ['state'=>'pending','pending'=>['Data de referência explícita em falta; nenhuma data foi presumida.']]; $this->date($date);
$read=function()use($service,$date,$scope){$result=$this->resolveInternal($service,$date,$scope);return $result+['revision'=>hash_hmac('sha256',json_encode($result,JSON_THROW_ON_ERROR),(string)config('app.key'))];};
if(DB::transactionLevel())return $read();
+4 -4
View File
@@ -37,7 +37,7 @@ final class DocumentPreparation
{
if ($kind === 'payment') { return $id ? $this->query($kind, $scope)->where('id', $id)->first() : null; }
$table = $kind === 'issuer' ? 'companies' : ($kind === 'client' ? 'a_clients' : 'a_'.$kind);
$query = DB::table($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
$query = \App\Shared\Context\ModuleRecords::query($table)->where('tenant_id', $scope['tenant_id'])->where($kind === 'issuer' ? 'id' : 'company_id', $scope['company_id'])->where('id', $id);
if ($scope['unit_id']) { $query->where('unit_id', $scope['unit_id']); }
$target = $query->first(); abort_unless($target, 403, 'Registo fora do âmbito autorizado.'); return $target;
}
@@ -47,7 +47,7 @@ final class DocumentPreparation
$scope = $this->scope($kind, $actor, 'view');
if ($kind === 'payment') { return $this->query($kind, $scope)->orderBy('id')->get()->map(fn ($row) => $this->decode($row))->all(); }
if ($kind === 'issuer') { return [['id' => $scope['company_id'], 'name' => DB::table('companies')->where('id', $scope['company_id'])->value('name')]]; }
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
if ($kind === 'client') { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'clients.view'); return \App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->orderBy('name')->get(['id','name'])->all(); }
return $this->workflow->list($kind, $actor);
}
@@ -124,7 +124,7 @@ final class DocumentPreparation
public function snapshot(int $draft, int $actor): ?array
{
$scope = $this->context->identifiers(); $scope['unit_id'] = $this->context->unitId();
$billing = DB::table('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
$billing = \App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where($scope)->where('id', $draft)->firstOrFail();
$rows = DB::table('doc_records')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where(function ($q) use ($billing, $draft) {
$q->where('kind', 'issuer')->orWhere(fn ($q) => $q->where('kind', 'client')->where('record_id', $billing->client_id))->orWhere(fn ($q) => $q->where('kind', 'billing_drafts')->where('record_id', $draft));
})->get();
@@ -148,7 +148,7 @@ final class DocumentPreparation
$row = $this->query('service_drafts', $scope)->where('record_id', $source)->first();
if (! $row) { return; }
$data = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
if ((int) $data['draft_version'] !== (int) DB::table('a_service_drafts')->where('id', $source)->value('version')) { return; }
if ((int) $data['draft_version'] !== (int) \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $source)->value('version')) { return; }
$data['draft_version'] = 1; $data['source_preparation'] = ['id' => $row->id, 'version' => $row->version];
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
$id = DB::table('doc_records')->insertGetId($scope + ['kind' => 'billing_drafts', 'record_id' => $billing, 'version' => 1, 'data' => $encoded, 'created_at' => now(), 'updated_at' => now()]);
+1 -1
View File
@@ -23,7 +23,7 @@ final class DraftValues
{
$result = [];
foreach ($lines as $index => $line) {
$service = DB::table('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first();
$service = \App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $line['service_id'])->first();
abort_unless($service, 403, 'Serviço fora da empresa autorizada.');
$result[] = ['service_id' => (int) $service->id, 'service_name' => $service->name,
'description' => $line['description'] ?? null,
@@ -10,7 +10,11 @@ use Illuminate\Http\Request;
final class ServiceController extends Controller
{
public function index(Request $request, string $area, ServiceWorkflow $workflow) { return response()->json(['records' => $workflow->list($area, $request->user()->id)]); }
public function index(Request $request, string $area, ServiceWorkflow $workflow) {
$records=$workflow->list($area, $request->user()->id);
return response()->json(['records'=>$records,'module_id'=>\App\Shared\Context\ModuleRecords::active(),
'scope_notice'=>'Apenas registos explicitamente associados ao módulo activo. Registos anteriores sem associação não foram eliminados: consulte Associações de módulos nas opções gerais.']);
}
public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow)
{
$record = $workflow->show($area, $id, $request->user()->id);
@@ -17,6 +17,7 @@ final class InternalConfirmation
public function show(int $draft, int $actor): ?array
{
$scope = $this->workflow->scope('billing_drafts', $actor, 'view');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where($scope)->where('id',$draft)->exists(),403,'Registo fora do módulo ou do âmbito autorizado.');
$row = DB::table('b_internal_confirmations')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('billing_draft_id', $draft)->first();
if (! $row) { return null; }
$history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id');
@@ -43,7 +44,7 @@ final class InternalConfirmation
abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
}
$billing = DB::table('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first();
$billing = \App\Shared\Context\ModuleRecords::query('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first();
abort_unless($billing, 403, 'Rascunho fora do âmbito autorizado.');
abort_unless((int) $billing->version === $version, 409, 'A versão mudou; reabra o rascunho.');
$existing = DB::table('b_internal_confirmations')->where('billing_draft_id', $draft)->first();
@@ -53,16 +54,16 @@ final class InternalConfirmation
$result = ['id' => $existing->id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$existing->id, 'event_id' => $event];
} else {
$data = json_decode($billing->data, true, 32, JSON_THROW_ON_ERROR);
$source = DB::table('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first();
$source = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first();
abort_unless($source && (int) $source->version === (int) $data['source_version'], 409, 'A prestação mudou; reveja e actualize explicitamente o rascunho antes de confirmar.');
$client = DB::table('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first();
$client = \App\Shared\Context\ModuleRecords::query('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first();
abort_unless($client && trim($client->name) !== '' && trim($billing->name) !== '', 422, 'Complete o cliente e a designação do percurso.');
abort_unless((int) $source->client_id === (int) $billing->client_id, 422, 'O cliente diverge da prestação de origem.');
$data['lines'] = DraftValues::lines($data['lines'], $scope);
foreach ($data['lines'] as $line) { abort_if($line['quantity'] === null || $line['unit_price'] === null || trim($line['service_name']) === '', 422, 'Complete quantidade, preço e serviço de todas as linhas.'); }
$pending = DraftValues::pending($data) + ['accounts_pending' => 'Contas e classificação por validar; contabilização bloqueada.', 'recognition_pending' => 'Momento e regras de reconhecimento por validar; contabilização bloqueada.'];
$catalogue = [];
foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = DB::table('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); }
foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = \App\Shared\Context\ModuleRecords::query('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); }
$data['client_name'] = $client->name;
$snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source),
'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data),
@@ -30,7 +30,7 @@ final class LocalFiscalActivation
foreach(['service_id','service_version'] as $key) abort_unless((int)$d[$key]===(int)$in[$key],409,'Serviço/versão diferente.');
abort_unless($d['valid_from']===$in['valid_from'] && ($d['valid_until'] ?? null)===($in['valid_until'] ?? null),409,'Vigência diferente.');
abort_unless($d['currency']==='MZN' && $d['price_mode']==='added' && $d['rate_percent']==='16' && $d['treatment']==='domestic_normal',409,'Tratamento diferente do recorte aprovado.');
abort_unless(DB::table('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
abort_unless(DB::table('sf_decisions')->where('proposal_id',$p->id)->where('decision','approved')->exists(),409,'Aprovação exacta em falta.');
abort_if(DB::table('sf_proposals')->where($scope)->where('kind','profile')->where('identifier',$p->identifier)->where('version','>',$p->version)->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'))->exists(),409,'Outra versão já foi aprovada; reveja a operação.');
$profile=DB::table('service_calculation_profiles')->where($scope)->where('id',$p->profile_id)->where('identifier',$p->identifier)->where('version',$p->version)->first();
+1 -1
View File
@@ -68,7 +68,7 @@ final class ServiceCalculation
'data' => $b['data'], 'prepared' => $p['data'], 'profile_id' => (int) $profile->id,
'profile_version' => (int) $profile->version, 'profile_identifier' => $profile->identifier,
'profile' => json_decode($profile->definition, true, 32, JSON_THROW_ON_ERROR), 'policy' => self::POLICY,
'source_current_version' => (int) DB::table('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
'source_current_version' => (int) \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id',$b['source_id'])->where('tenant_id',$b['tenant_id'])->where('company_id',$b['company_id'])->where('unit_id',$b['unit_id'])->value('version')];
}
private function fingerprint(array $input): string { return hash('sha256', json_encode($input, JSON_THROW_ON_ERROR)); }
+3 -3
View File
@@ -69,7 +69,7 @@ final class ServiceFiscal
$version=(int)($last?->version ?? 0)+1;
if ($in['kind']==='profile') {
$d=$this->exact($in['data'],['service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:added','rate_percent'=>'required|in:16','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','treatment'=>'required|in:domestic_normal','conditions'=>'required|string|max:4000','evidence'=>'required|string|max:4000']);
$service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'],409,'Serviço/versão incompatível.');
if ($previous) abort_unless((int)$this->data($previous)['service_id']===(int)$d['service_id'],422,'Nova versão não pode trocar a identidade do serviço.');
abort_if(DB::table('sf_proposals')->where($s)->where('kind','profile')->whereRaw("data->>'service_id' = ?",[(string)$d['service_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade existente deste serviço e uma nova versão.');
@@ -126,7 +126,7 @@ final class ServiceFiscal
private function versions(array $b,array $p,array $d): void
{
abort_unless($b['version']===(int)$d['draft_version'] && $p['version']===(int)$d['preparation_version'] && ($p['data']['draft_version'] ?? null)===$b['version'],409,'Rascunho/preparação desactualizados.');
$current=DB::table('a_service_drafts')->where('id',$b['source_id'])->value('version');
$current=\App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id',$b['source_id'])->value('version');
abort_unless((int)$current===(int)$b['data']['source_version'],409,'Origem desactualizada.');
}
private function compatible(object $p,array $s,string $date,array $b,array $prepared,bool $active=true): array
@@ -134,7 +134,7 @@ final class ServiceFiscal
$d=$this->data($p); $line=$b['data']['lines'][0] ?? [];
abort_unless($p->kind==='profile' && $this->approved($p) && (!$active || $this->activated($p)) && $this->latest($p,$s,$date),409,'Perfil sem aprovação/activação exacta ou substituído.');
abort_unless($date!=='' && $d['valid_from']<=$date && (empty($d['valid_until']) || $date<=$d['valid_until']),422,'Vigência não demonstrada.');
$service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
abort_unless($service && (int)$service->version===(int)$d['service_version'] && count($b['data']['lines'])===1 && (int)($line['service_id'] ?? 0)===(int)$d['service_id'] && $b['data']['currency']===$d['currency'] && ($prepared['data']['price_mode'] ?? null)===$d['price_mode'],422,'Serviço, moeda ou modo incompatível.');
return $d;
}
+4 -4
View File
@@ -101,7 +101,7 @@ final class ServiceInvoicing
if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
TestingDatabaseGuard::assertSafe();
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>DB::table('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
}
public function review(int $draft,int $series,int $rule,int $actor): array
{
@@ -167,7 +167,7 @@ final class ServiceInvoicing
$snapshot=$this->snapshot($p,$number,$instant);
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
$bytes=$this->pdf->render($snapshot);
$id=DB::table('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
$id=\App\Shared\Context\ModuleRecords::query('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
$event=DB::table('si_events')->insertGetId(['invoice_id'=>$id,'type'=>'test_invoice.captured','payload'=>json_encode(['invoice_id'=>$id,'simulation_only'=>true],JSON_THROW_ON_ERROR),'created_at'=>now()]);
DB::table('si_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_processing']);
Audit::record('service_invoice.test_issued',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'event_id'=>$event,'version'=>1]);
@@ -179,13 +179,13 @@ final class ServiceInvoicing
public function show(int $id,int $actor): array
{
self::guard(); $s=$this->scope($actor,'service_invoices.view');
$r=DB::table('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR);
return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()];
}
public function pdf(int $id,int $actor): string
{
$this->show($id,$actor); $r=DB::table('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$this->show($id,$actor); $r=\App\Shared\Context\ModuleRecords::query('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
$bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes;
}
public function requestCorrection(int $id,int $actor,array $input): array
+2 -2
View File
@@ -90,7 +90,7 @@ final class ServiceReceivables
$right=$kind==='correction'?'correction_request':'prepare';
if(isset($d['amount'])) $d['amount']=self::money($d['amount'],$kind!=='observation');
return $this->command($actor,$right,$key,$d,function($s)use($actor,$d,$kind) {
$inv=DB::table('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
$inv=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->where('id',$d['invoice_id'])->first();
abort_unless($inv,403,'Documento de origem não autorizado.');
$snapshot=json_decode($inv->snapshot,true,32,JSON_THROW_ON_ERROR);
abort_unless(($snapshot['simulation_only']??false)===true && ($snapshot['notice']??'')===self::NOTICE && ($snapshot['document']['data']['currency']??'')==='MZN',422,'Origem sintética compatível necessária.');
@@ -222,7 +222,7 @@ final class ServiceReceivables
return DB::transaction(function()use($s) {
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
$records=DB::table('sr_records')->where($s)->orderBy('id')->get();
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>DB::table('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
});
}
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
+13 -6
View File
@@ -25,7 +25,7 @@ final class ServiceWorkflow
private function query(string $area, array $scope)
{
return DB::table('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']);
return \App\Shared\Context\ModuleRecords::query('a_'.$area)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id']);
}
public function list(string $area, int $actor): array
@@ -74,7 +74,9 @@ final class ServiceWorkflow
$scope = $this->scope($area, $actor, $action);
$selectedUnit = $this->context->unitId();
abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.');
$hash = hash('sha256', json_encode($this->canonical([$area, $id, $scope, $input]), JSON_THROW_ON_ERROR));
$identity=[$area, $id, $scope, $input];
if($module=\App\Shared\Context\ModuleRecords::active())$identity[]=$module;
$hash = hash('sha256', json_encode($this->canonical($identity), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($area, $id, $actor, $input, $key, $scope, $selectedUnit, $hash, $action): array {
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $selectedUnit);
@@ -99,13 +101,18 @@ final class ServiceWorkflow
$data = $values['data']; $values['data'] = json_encode($data === [] ? new \stdClass() : $data, JSON_THROW_ON_ERROR);
$values += ['version' => $version, 'updated_at' => now()];
if ($id) { $this->query($area, $scope)->where('id', $id)->update($values); }
else { $id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]); }
else {
$id = DB::table('a_'.$area)->insertGetId($scope + $values + ['created_at' => now()]);
if ($module=\App\Shared\Context\ModuleRecords::active()) {
\App\Shared\Context\ModuleRecords::associate($area,$id,$actor,$scope,$module,'Origem explícita da criação no módulo activo.',false,$version);
}
}
$record = $this->query($area, $scope)->where('id', $id)->first();
if ($area === 'billing_drafts' && ! $old) { app(DocumentPreparation::class)->inheritToBilling((int) $record->source_id, $id, $actor, $scope); }
$snapshot = (array) $record; $snapshot['data'] = $data;
$pending = ServicePermissions::unitScoped($area) ? DraftValues::pending($data) : [];
if ($area === 'billing_drafts') {
$current = DB::table('a_service_drafts')->where('id', $record->source_id)->value('version');
$current = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('id', $record->source_id)->value('version');
if ((int) $current !== (int) $data['source_version']) { $pending['source_changed'] = 'A prestação tem uma versão mais recente. Reveja ou actualize explicitamente os dados de origem.'; }
}
$snapshot['pending'] = $pending;
@@ -147,7 +154,7 @@ final class ServiceWorkflow
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked('service_drafts', (int)$sourceId),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
}
$source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
$source = \App\Shared\Context\ModuleRecords::query('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first();
abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
$reload = ! $old || ($input['reload_source'] ?? false);
if ($reload) { $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'service_drafts.view', $scope['unit_id']); }
@@ -161,7 +168,7 @@ final class ServiceWorkflow
} else {
$clientId = $input['client_id']; $lines = $input['lines']; $currency = $input['currency'] ?? null;
}
$client = DB::table('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first();
$client = \App\Shared\Context\ModuleRecords::query('a_clients')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $clientId)->first();
abort_unless($client, 403, 'Cliente fora da empresa autorizada.');
$data = ['description' => $input['description'] ?? ($base['description'] ?? ($oldData['description'] ?? null)), 'currency' => $currency,
'lines' => DraftValues::lines($lines, $scope), 'client_name' => $client->name, 'tax_status' => 'pending', 'accounting_status' => 'not_processed'];
+3 -3
View File
@@ -35,13 +35,13 @@ final class WorkshopBilling {
}
private function compose(int $order,array $input,array $s,bool $allowClaim=false): array {
$o=$this->order($order,$s);abort_unless($input['client_id']===$o->client_id&&$input['currency']==='MZN',409,'Cliente ou moeda incompatível.');
$client=DB::table('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
$client=\App\Shared\Context\ModuleRecords::query('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409);
abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[];
foreach($input['lines'] as $line){
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120']);
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->origin]),409,'Origem repetida.');$seen[$e->origin]=true;
abort_if(!$allowClaim&&DB::table('wb_claims')->where(['order_id'=>$order,'origin'=>$e->origin])->exists(),409,'Origem já utilizada comercialmente.');
$service=DB::table('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($d['price'],'price');abort_if($price===null,422,'Preço desconhecido não permite gerar.');
$quantity=$e->kind==='time'?Exact::text(Exact::hours($e->minutes)):$e->quantity;$quantity=DraftValues::decimal($quantity,'quantity',true);
$lines[]=['entry_id'=>$e->id,'entry_version'=>$e->version,'origin'=>$e->origin,'task_id'=>$e->task_id,'source_kind'=>$e->kind,'source_quantity'=>$e->kind==='time'?(string)$e->minutes:$e->quantity,'unit'=>$e->kind==='time'?'H':$e->data['unit'],'service_id'=>$service->id,'service_version'=>$service->version,'quantity'=>$quantity,'unit_price'=>$price,'price_source'=>$d['price_source'],'description'=>"Oficina #{$order} · origem #{$e->id} v{$e->version} · ".($e->kind==='time'?'Trabalho efectivo':$e->data['resource'])];
@@ -86,6 +86,6 @@ final class WorkshopBilling {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];self::guard();
return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];foreach(DB::table('wo_orders')->where($s)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}}
$proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}}
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
$orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>$this->generationState($o->id)];}return ['blocked'=>false,'orders'=>$orders,'services'=>\App\Shared\Context\ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
+5 -5
View File
@@ -24,7 +24,7 @@ final class WorkshopCorrections {
return ['sequence'=>$a?->sequence??0,'service_id'=>$a?->new_service_id??$g->service_draft_id,'billing_id'=>$a?->new_billing_id??$g->billing_draft_id];
}
private function baseline(object $g,array $s): array {
$c=$this->current($g);$service=DB::table('a_service_drafts')->find($c['service_id']);$bill=DB::table('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
$c=$this->current($g);$service=\App\Shared\Context\ModuleRecords::query('a_service_drafts')->find($c['service_id']);$bill=\App\Shared\Context\ModuleRecords::query('a_billing_drafts')->find($c['billing_id']);$order=DB::table('wo_orders')->find($g->order_id);
abort_unless($service&&$bill&&$service->state==='draft'&&$bill->state==='draft'&&$bill->source_id===$service->id&&$bill->client_id===$order->client_id&&$service->client_id===$order->client_id,409,'Destino ou cliente incompatível.');
WorkshopCommercialState::assertCurrent('billing_drafts',$bill->id);
abort_if(DB::table('b_internal_confirmations')->where('billing_draft_id',$bill->id)->exists()||DB::table('a_billing_drafts')->where('source_id',$service->id)->where('id','<>',$bill->id)->exists(),409,'Destino confirmado ou com dependências.');
@@ -32,14 +32,14 @@ final class WorkshopCorrections {
abort_if(DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->where(fn($q)=>$q->where(fn($x)=>$x->where('kind','billing_drafts')->where('record_id',$bill->id))->orWhere(fn($x)=>$x->where('kind','service_drafts')->where('record_id',$service->id)))->exists(),409,'Preparação documental existente: herança exige revisão própria.');
$origin=json_decode(DB::table('wb_proposals')->find($g->proposal_id)->snapshot,true,64,JSON_THROW_ON_ERROR);
foreach($origin['lines'] as $line){$e=DB::table('wo_entries')->where(['id'=>$line['entry_id'],'version'=>$line['entry_version'],'order_id'=>$g->order_id])->first();abort_unless($e&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$e->id)->exists()&&!DB::table('wo_entries')->where('previous_id',$e->task_id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$e->id,'entry_version'=>$e->version,'decision'=>'verified'])->exists(),409,'Origem técnica desactualizada ou não verificada.');}
$bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>DB::table('a_clients')->find($bill->client_id)->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
$client=\App\Shared\Context\ModuleRecords::query('a_clients')->find($bill->client_id)??abort(409,'Associação do cliente ao módulo activo pendente.');$bd=json_decode($bill->data,true);abort_unless($bd['currency']==='MZN',409,'Moeda incompatível.');
return $c+['service_version'=>$service->version,'billing_version'=>$bill->version,'client_id'=>$bill->client_id,'client_version'=>$client->version,'currency'=>'MZN','current_base_exact'=>DraftValues::base($bd),'original_origins'=>$origin['lines'],'current_lines'=>$bd['lines'],'doc_versions'=>DB::table('doc_records')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->get(['id','version'])->map(fn($r)=>(array)$r)->all()];
}
private function commercial(array $base,array $input,array $s): array {
abort_unless(count($input)===count($base['original_origins']),422,'Manter todas as origens integrais.');$out=[];
foreach($base['original_origins'] as $i=>$origin){$l=$this->exact($input[$i],['entry_id'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120','description'=>'required|string|min:3|max:500']);
abort_unless($l['entry_id']===$origin['entry_id'],409,'Não pode substituir ou reordenar origens.');
$service=DB::table('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$service=\App\Shared\Context\ModuleRecords::query('a_services')->where(['id'=>$l['service_id'],'version'=>$l['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.');
$price=DraftValues::decimal($l['price'],'preço comercial');abort_if($price===null,422,'Preço desconhecido.');
$out[]=array_replace($origin,['service_id'=>$service->id,'service_version'=>$service->version,'unit_price'=>$price,'price_source'=>$l['price_source'],'description'=>$l['description']]);
}return $out;
@@ -76,6 +76,6 @@ final class WorkshopCorrections {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'generations'=>[]];self::guard();return DB::transaction(function()use($s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$generations=[];
foreach(DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->get() as $g){$current=null;$issue=null;try{$current=$this->baseline($g,$s);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$issue=$e->getMessage();}$requests=DB::table('wc_requests')->where('generation_id',$g->id)->orderBy('version')->get()->all();foreach($requests as $r){$r->snapshot=json_decode($r->snapshot,true);$r->review=DB::table('wc_reviews')->where('request_id',$r->id)->first();$r->applied=DB::table('wc_applications')->where('request_id',$r->id)->first();$r->current=!DB::table('wc_requests')->where('previous_id',$r->id)->exists()&&$current!==null&&$this->canonical($r->snapshot['baseline'])===$this->canonical($current);}
$generations[]=(array)$g+['current'=>$current,'issue'=>$issue,'requests'=>$requests,'applications'=>DB::table('wc_applications')->where('generation_id',$g->id)->orderBy('sequence')->get()->all()];}
return ['blocked'=>false,'generations'=>$generations,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
return ['blocked'=>false,'generations'=>$generations,'services'=>\App\Shared\Context\ModuleRecords::query('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
}
}
+2 -2
View File
@@ -79,7 +79,7 @@ final class WorkshopWorkflow
self::guard();
$d=$this->exact($input,['reference'=>'required|string|min:3|max:100','client_id'=>'required|integer|min:1','equipment_id'=>'nullable|integer|min:1','equipment_identifier'=>'required_without:equipment_id|string|max:100','equipment_description'=>'required_without:equipment_id|string|max:300','serial'=>'nullable|string|max:100','request'=>'required|string|min:3|max:2000','location'=>'required|string|max:300','evidence'=>'required|string|min:3|max:500']);
return $this->command($actor,'open',$key,$d,null,function($s)use($d,$actor) {
abort_unless(DB::table('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
abort_unless(\App\Shared\Context\ModuleRecords::query('a_clients')->where(['id'=>$d['client_id'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->exists(),403,'Cliente de outra empresa.');
abort_if(DB::table('wo_orders')->where(['company_id'=>$s['company_id'],'reference'=>$d['reference']])->exists(),409,'Referência de ordem já existente.');
$equipment=$d['equipment_id']??null;
if($equipment) abort_unless(DB::table('wo_equipment')->where(['id'=>$equipment,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'client_id'=>$d['client_id']])->exists(),403,'Equipamento incompatível com o cliente.');
@@ -231,7 +231,7 @@ final class WorkshopWorkflow
$orders[]=(array)$o+['equipment'=>DB::table('wo_equipment')->find($o->equipment_id),'revision'=>$this->revision($o->id),'assigned_to'=>$entries->where('kind','assignment')->last()?->technician_id,'state'=>$state,'entries'=>$entries->all(),'minutes_reported'=>$current->where('kind','time')->sum('minutes'),'minutes_verified'=>$current->where('kind','time')->filter(fn($e)=>$e->decision?->decision==='verified')->sum('minutes'),'materials'=>array_values($materials)];
}
$techs=$this->has($actor,'assign',$s)?DB::table('permission_grants as g')->join('users as u','u.id','=','g.user_id')->where(['g.company_id'=>$s['company_id'],'g.unit_id'=>$s['unit_id'],'g.permission'=>'workshop.record'])->get(['u.id','u.name'])->all():[];
$clients=DB::table('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
$clients=\App\Shared\Context\ModuleRecords::query('a_clients')->where(['company_id'=>$s['company_id'],'tenant_id'=>$s['tenant_id']]);
if(!$this->has($actor,'open',$s)) $clients->whereIn('id',array_column($orders,'client_id'));
return ['blocked'=>false,'notice'=>self::NOTICE,'policy'=>self::POLICY,'orders'=>$orders,'clients'=>$clients->get(['id','name'])->all(),'technicians'=>$techs];
});