commit4
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class BudgetController extends Controller
|
||||
{
|
||||
public function index(Request $request, BudgetWorkspace $workspace)
|
||||
{
|
||||
return response()->json($workspace->listing($request->user()))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function command(Request $request, string $action, BudgetWorkspace $workspace)
|
||||
{
|
||||
return response()->json($workspace->command($request->user(), $action, $request->all(), (string) $request->header('Idempotency-Key')))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tempo\Finance\Budget\BudgetFailure;
|
||||
|
||||
/** Synthetic UI/application orchestration only. All financial effects delegate to BudgetControl. */
|
||||
final class BudgetWorkspace
|
||||
{
|
||||
private function scope(User $user, string $right): array
|
||||
{
|
||||
BudgetAdapter::guard();
|
||||
$context = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $user->id && $context->userId() === $user->id && $context->unitId() !== null, 403);
|
||||
$s = $context->identifiers() + ['unit_id' => $context->unitId()];
|
||||
$context->selectFor($user->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
|
||||
foreach (array_unique(['view', $right]) as $permission) {
|
||||
app(Permissions::class)->require($user->id, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$permission, $s['unit_id']);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function input(array $data, array $rules): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($data), array_keys($rules)) !== [], 422, 'Campo protegido ou fora do percurso sintético.');
|
||||
|
||||
return Validator::make($data, $rules)->validate();
|
||||
}
|
||||
|
||||
private function money(string $value): int
|
||||
{
|
||||
if (! preg_match('/^(0|[1-9][0-9]{0,11})(\.[0-9]{1,2})?$/D', $value)) {
|
||||
throw ValidationException::withMessages(['amount' => 'Use um montante MZN com ponto e até duas casas, sem arredondamento.']);
|
||||
}
|
||||
|
||||
return BigDecimal::of($value)->multipliedBy(100)->toInt();
|
||||
}
|
||||
|
||||
public function listing(User $user): array
|
||||
{
|
||||
$s = $this->scope($user, 'view');
|
||||
$engine = app(BudgetAdapter::class)->for($user);
|
||||
$budgets = DB::table('cb_budgets')->where($s)->orderBy('id')->get()->map(fn ($b) => (array) $b + ['balance' => $engine->balance($b->id)])->all();
|
||||
$names = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id'])->pluck('u.name', 'u.id')->all();
|
||||
|
||||
return ['synthetic_only' => true, 'currency' => 'MZN', 'current_year' => $engine->context->year, 'budgets' => $budgets,
|
||||
'centres' => DB::table('cb_centres')->where($s)->orderBy('id')->get(),
|
||||
'origins' => DB::table('cb_origins')->where($s)->orderBy('id')->get(),
|
||||
'entries' => DB::table('cb_budget_entries')->where($s)->orderBy('id')->get(),
|
||||
'consumptions' => DB::table('cb_budget_consumptions')->where($s)->orderBy('id')->get(),
|
||||
'consumption_sources' => DB::table('cb_consumption_sources')->where($s)->orderBy('id')->get(),
|
||||
'reinforcements' => DB::table('cb_budget_reinforcements')->where($s)->orderBy('id')->get(['id', 'budget_id', 'created_by', 'reviewed_by', 'reference', 'amount_minor', 'reason', 'status', 'review_reason']),
|
||||
'history' => DB::table('cb_audit')->where($s)->orderBy('id')->get(), 'actors' => $names];
|
||||
}
|
||||
|
||||
public function command(User $user, string $action, array $input, string $key): array
|
||||
{
|
||||
$right = match ($action) {
|
||||
'create' => 'create', 'approve' => 'approve', 'movement' => 'record', 'consume' => 'consume',
|
||||
'reinforcement' => 'reinforcement.propose', 'review' => 'reinforcement.review', default => abort(404),
|
||||
};
|
||||
$s = $this->scope($user, $right);
|
||||
abort_unless(Str::isUuid($key), 422, 'Chave de comando em falta.');
|
||||
ksort($input);
|
||||
$hash = hash('sha256', json_encode($input, JSON_THROW_ON_ERROR));
|
||||
try {
|
||||
return DB::transaction(function () use ($user, $action, $right, $input, $key, $hash, $s) {
|
||||
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->scope($user, $right);
|
||||
$commands = DB::table('cb_ui_commands')->where($s)->where('actor_id', $user->id)->where('command_key', $key);
|
||||
if ($old = $commands->first()) {
|
||||
abort_unless($old->action === $action && hash_equals($old->request_hash, $hash), 409, 'Chave já usada com outro conteúdo.');
|
||||
|
||||
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
|
||||
}
|
||||
$adapter = app(BudgetAdapter::class);
|
||||
$engine = $adapter->for($user);
|
||||
$idRule = 'required|integer|min:1';
|
||||
if ($action === 'create') {
|
||||
$d = $this->input($input, ['cost_center_id' => $idRule, 'year' => 'required|integer|min:2026|max:2100', 'kind' => 'required|in:operating,investment', 'includes_tax' => 'required|boolean', 'amount' => 'required|string', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$d['amount_minor'] = $this->money($d['amount']);
|
||||
unset($d['amount']);
|
||||
$d['includes_tax'] = (bool) $d['includes_tax'];
|
||||
$id = $adapter->create($user, $d);
|
||||
} elseif ($action === 'approve') {
|
||||
$d = $this->input($input, ['budget_id' => $idRule]);
|
||||
$id = $d['budget_id'];
|
||||
$engine->approve($id);
|
||||
} elseif ($action === 'reinforcement') {
|
||||
$d = $this->input($input, ['budget_id' => $idRule, 'amount' => 'required|string', 'reference' => 'required|string|max:100', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$id = $engine->proposeReinforcement($d['budget_id'], $this->money($d['amount']), Str::upper(Str::squish($d['reference'])), trim($d['reason']));
|
||||
} elseif ($action === 'review') {
|
||||
$d = $this->input($input, ['reinforcement_id' => $idRule, 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:3|max:4000']);
|
||||
$id = $d['reinforcement_id'];
|
||||
$engine->reviewReinforcement($id, $d['decision'], $d['reason']);
|
||||
} else {
|
||||
$rules = ['origin_id' => $idRule, 'total' => 'required|string', 'subtotal' => 'required|string'];
|
||||
$rules += $action === 'movement' ? ['kind' => 'required|in:reserve,award,commit,release'] : ['reference' => 'required|string|min:3|max:100'];
|
||||
$d = $this->input($input, $rules);
|
||||
$origin = DB::table('cb_origins')->where($s)->where('id', $d['origin_id'])->first() ?? abort(403, 'Origem fora deste contexto.');
|
||||
$total = $this->money($d['total']);
|
||||
$net = $this->money($d['subtotal']);
|
||||
abort_if($net > $total, 422, 'Subtotal não pode exceder o total indicado.');
|
||||
$entries = DB::table('cb_budget_entries')->where($s)->where('requisition_id', $origin->id);
|
||||
if ($action === 'movement') {
|
||||
$kind = $d['kind'];
|
||||
if (! (clone $entries)->where('kind', $kind)->exists()) {
|
||||
// Procurement sequence, not budget arithmetic. No reopening or invented reversal.
|
||||
$reserved = (clone $entries)->where('kind', 'reserve')->exists();
|
||||
$closed = (clone $entries)->whereIn('kind', ['commit', 'release'])->exists();
|
||||
abort_if($closed || ($kind !== 'reserve' && ! $reserved) || ($kind === 'commit' && ! (clone $entries)->where('kind', 'award')->exists()), 409, 'Sequência indisponível: reserve, ajuste a adjudicação e depois comprometa; libertação apenas antes do compromisso.');
|
||||
}
|
||||
$effect = $adapter->record($user, $origin->id, $kind, $total, $net);
|
||||
abort_unless($effect, 409, 'Falta preparar e aprovar uma dotação para este centro/exercício.');
|
||||
$id = $effect['budget_id'];
|
||||
} else {
|
||||
$reference = Str::upper(Str::squish($d['reference']));
|
||||
$sources = DB::table('cb_consumption_sources')->where($s)->where('reference', $reference);
|
||||
if ($source = $sources->first()) {
|
||||
abort_unless((int) $source->origin_id === $origin->id && (int) $source->total_minor === $total && (int) $source->subtotal_minor === $net, 409, 'Referência sintética já usada com outros valores ou origem.');
|
||||
$id = $source->id;
|
||||
} else {
|
||||
$id = DB::table('cb_consumption_sources')->insertGetId($s + ['origin_id' => $origin->id, 'reference' => $reference, 'total_minor' => $total, 'subtotal_minor' => $net, 'created_by' => $user->id, 'created_at' => now()]);
|
||||
}
|
||||
$engine->consume($origin->id, $id, $total, $net);
|
||||
}
|
||||
}
|
||||
$result = ['id' => (int) $id, 'action' => $action];
|
||||
DB::table('cb_ui_commands')->insert($s + ['actor_id' => $user->id, 'command_key' => $key, 'action' => $action, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
|
||||
return $result + ['replayed' => false];
|
||||
});
|
||||
} catch (BudgetFailure $e) {
|
||||
abort_if($e->status !== 422, $e->status, $e->getMessage());
|
||||
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
final class SupplierPayables
|
||||
{
|
||||
public static function guard(): void
|
||||
{
|
||||
$c = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.');
|
||||
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
|
||||
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
|
||||
}
|
||||
}
|
||||
|
||||
public function scope(User $u): array
|
||||
{
|
||||
self::guard();
|
||||
$c = app(CompanyContext::class);
|
||||
abort_unless(auth()->id() === $u->id && $c->userId() === $u->id && $c->unitId(), 403);
|
||||
$ids = $c->identifiers();
|
||||
app(Permissions::class)->require($u->id, $ids['tenant_id'], $ids['company_id'], 'supplier_payables.view', $c->unitId());
|
||||
|
||||
return $c->identifiers() + ['unit_id' => $c->unitId()];
|
||||
}
|
||||
|
||||
public function can(User $u, string $a): bool
|
||||
{
|
||||
return DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $a])->exists();
|
||||
}
|
||||
|
||||
public function document(User $u, int $id): array
|
||||
{
|
||||
$row = DB::table('pa_imported_sources')->where($this->scope($u))->where('id', $id)->first() ?? abort(403, 'Origem não mapeada neste contexto.');
|
||||
$s = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($row->receipt_hash, hash('sha256', Payables::canonical($s))), 409, 'Recibo de origem divergente.');
|
||||
$s['local_id'] = $id;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function engine(User $u): Payables
|
||||
{
|
||||
$scope = $this->scope($u);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless($person, 403);
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, function (string $a) use ($u, $scope, $person) {
|
||||
self::guard();
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
|
||||
abort_unless($this->scope($u) === $scope, 409, 'Contexto mudou.');
|
||||
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
abort_unless($this->can($u, 'view') && $this->can($u, $a), 403);
|
||||
}, fn (int $id) => $this->document($u, $id));
|
||||
}
|
||||
|
||||
public function listing(User $u): array
|
||||
{
|
||||
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user()))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user());
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s)
|
||||
{
|
||||
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
|
||||
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
|
||||
try {
|
||||
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ final class DocumentPreparation
|
||||
$data = array_intersect_key($input, array_flip($fields));
|
||||
if ($scope['unit_id']) {
|
||||
$this->workflow->scope($kind, $actor, 'view');
|
||||
\App\Modules\Workshop\WorkshopCommercialState::assertCurrent($kind, $id);
|
||||
abort_if($kind === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'A fotografia e o rascunho B1 estão bloqueados.');
|
||||
abort_unless((int) $target->version === $input['draft_version'], 409, 'O rascunho mudou; reveja a preparação.');
|
||||
$draftData = json_decode($target->data, true, 32, JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -37,6 +37,7 @@ final class InternalConfirmation
|
||||
$this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
$this->workflow->scope('billing_drafts', $actor, 'view');
|
||||
$this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']);
|
||||
\App\Modules\Workshop\WorkshopCommercialState::assertCurrent('billing_drafts', $draft);
|
||||
$old = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first();
|
||||
if ($old) {
|
||||
abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.');
|
||||
|
||||
@@ -55,6 +55,9 @@ final class ServiceWorkflow
|
||||
$result = (array) $row;
|
||||
$result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR);
|
||||
$result['version'] = (int) $row->version;
|
||||
if (in_array($area, ['service_drafts','billing_drafts'], true)) {
|
||||
$result['workshop_commercial_state'] = \App\Modules\Workshop\WorkshopCommercialState::state($area, (int)$row->id);
|
||||
}
|
||||
if ($area === 'billing_drafts') {
|
||||
$confirmationId = DB::table('b_internal_confirmations')->where('billing_draft_id', $row->id)->value('id');
|
||||
$result['internal_confirmation'] = $confirmationId ? ['reference' => 'INT-B1-'.$confirmationId, 'label' => InternalConfirmation::LABEL] : null;
|
||||
@@ -86,7 +89,7 @@ final class ServiceWorkflow
|
||||
abort_unless($old, 403, 'Registo fora do âmbito autorizado.');
|
||||
if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
|
||||
$column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id';
|
||||
abort_if(DB::table('wb_generations')->where($column, $id)->exists(),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
|
||||
abort_if(\App\Modules\Workshop\WorkshopCommercialState::linked($area, $id),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.');
|
||||
}
|
||||
abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.');
|
||||
abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.');
|
||||
@@ -142,7 +145,7 @@ final class ServiceWorkflow
|
||||
if ($area === 'billing_drafts') {
|
||||
$sourceId = $old ? $old->source_id : $input['source_id'];
|
||||
if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) {
|
||||
abort_if(DB::table('wb_generations')->where('service_draft_id', $sourceId)->exists(),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.');
|
||||
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();
|
||||
abort_unless($source, 403, 'Prestação fora da unidade autorizada.');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Workshop\WorkshopCorrections;
|
||||
use Illuminate\Http\Request;
|
||||
final class WorkshopCorrectionsController extends Controller {
|
||||
private function response(array $d){return response()->json($d)->header('Cache-Control','no-store, private');}
|
||||
public function index(Request $r,WorkshopCorrections $c){return $this->response($c->listing($r->user()->id));}
|
||||
public function request(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->request($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
public function review(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->review($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
public function apply(Request $r,int $id,WorkshopCorrections $c){return $this->response($c->apply($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
}
|
||||
@@ -76,10 +76,16 @@ final class WorkshopBilling {
|
||||
$billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40));
|
||||
$id=DB::table('wb_generations')->insertGetId(['order_id'=>$order,'proposal_id'=>$p->id,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'actor_id'=>$actor,'created_at'=>now()]);foreach($snap['lines'] as $l)DB::table('wb_claims')->insert(['generation_id'=>$id,'order_id'=>$order,'origin'=>$l['origin'],'entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version']]);return ['id'=>$id,'version'=>$p->version,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'base_exact'=>$snap['base_exact']];});
|
||||
}
|
||||
private function generationState(int $order): ?object {
|
||||
$g=DB::table('wb_generations')->where('order_id',$order)->first();if(!$g)return null;
|
||||
$g->commercial_state=WorkshopCommercialState::state('billing_drafts',$g->billing_draft_id);
|
||||
$g->latest_commercial_change=\Illuminate\Support\Facades\Schema::hasTable('wc_applications')?DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first():null;
|
||||
return $g;
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$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'=>DB::table('wb_generations')->where('order_id',$o->id)->first()];}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'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
use Illuminate\Support\Facades\{DB,Schema};
|
||||
final class WorkshopCommercialState {
|
||||
public static function linked(string $area,int $id): bool {
|
||||
if(!Schema::hasTable('wb_generations'))return false;$column=$area==='service_drafts'?'service_draft_id':'billing_draft_id';
|
||||
if(DB::table('wb_generations')->where($column,$id)->exists())return true;
|
||||
if(!Schema::hasTable('wc_applications'))return false;
|
||||
return DB::table('wc_applications')->where($area==='service_drafts'?'new_service_id':'new_billing_id',$id)->exists();
|
||||
}
|
||||
public static function state(string $area,int $id): ?string {
|
||||
if(!self::linked($area,$id))return null;if(!Schema::hasTable('wc_applications'))return 'current';
|
||||
$event=DB::table('wc_applications')->where($area==='service_drafts'?'old_service_id':'old_billing_id',$id)->first();
|
||||
return $event?($event->kind==='replace'?'superseded':'cancelled'):'current';
|
||||
}
|
||||
public static function assertCurrent(string $area,int $id): void {
|
||||
$state=self::state($area,$id);abort_if($state!==null&&$state!=='current',409,'Preparação comercial substituída ou cancelada; apenas consulta histórica.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
use App\Modules\Identity\Services\{Permissions,Audit};
|
||||
use App\Modules\Services\{ServiceWorkflow,DraftValues,ServiceCalculation};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\{DB,Validator};
|
||||
final class WorkshopCorrections {
|
||||
public const POLICY='WORKSHOP-BILLING-CORRECTION-SIM-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private ServiceWorkflow $workflow){}
|
||||
public static function guard(): void {WorkshopBilling::guard();}
|
||||
private function json($v): string{return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
|
||||
private function canonical($v){if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
|
||||
private function exact(array $d,array $rules): array{abort_if(array_diff(array_keys($d),array_keys($rules)),422,'Campo fora da correcção comercial.');return Validator::make($d,$rules,['required'=>'Preencha :attribute.','present'=>'Indique :attribute.'])->validate();}
|
||||
private function scope(int $actor,string $right): array {
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($this->context->userId()===$actor&&$s['unit_id']!==null,403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(['workshop_corrections.view','workshop_corrections.'.$right,'workshop_billing.view','workshop.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
|
||||
foreach(['clients.view','services.view'] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p);return $s;
|
||||
}
|
||||
private function root(int $id,array $s): object {
|
||||
$g=DB::table('wb_generations as g')->join('wo_orders as o','o.id','=','g.order_id')->where('g.id',$id)->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->select('g.*')->first();return $g??abort(403,'Geração fora do contexto.');
|
||||
}
|
||||
private function current(object $g): array {
|
||||
$a=DB::table('wc_applications')->where('generation_id',$g->id)->orderByDesc('sequence')->first();abort_if($a?->kind==='cancel',409,'Preparação cancelada; origens não reabertas.');
|
||||
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);
|
||||
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.');
|
||||
// Conservative pilot: prepared document payloads require a separately reviewed inheritance path.
|
||||
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()];
|
||||
}
|
||||
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.');
|
||||
$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;
|
||||
}
|
||||
private function cmd(int $generation,int $actor,string $action,array $in,string $key,callable $write): array {
|
||||
self::guard();$s=$this->scope($actor,$action);$this->root($generation,$s);abort_unless(preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422);$hash=hash('sha256',$this->json($this->canonical($in)));
|
||||
return DB::transaction(function()use($generation,$actor,$action,$in,$key,$write,$s,$hash){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$action);$g=$this->root($generation,$s);
|
||||
if($action==='apply'){foreach(['service_drafts','billing_drafts'] as $area)$this->workflow->scope($area,$actor,'create');}
|
||||
$old=DB::table('wc_commands')->where(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave com conteúdo diferente.');return json_decode($old->result,true)+['replayed'=>true];}
|
||||
$result=$write($g,$s);$command=DB::table('wc_commands')->insertGetId(['generation_id'=>$generation,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($result),'created_at'=>now()]);
|
||||
Audit::record('workshop_corrections.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$result['id'],'version'=>$result['version']]);$event=DB::table('wc_events')->insertGetId(['command_id'=>$command,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$result]),'created_at'=>now()]);DB::table('wc_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $result+['replayed'=>false];},3);
|
||||
}
|
||||
public function request(int $generation,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','kind'=>'required|in:replace,cancel,withdraw','billing_id'=>'required|integer|min:1','billing_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','lines'=>'present|array','reason'=>'required|string|min:3|max:1000']);
|
||||
return $this->cmd($generation,$actor,'request',$d,$key,function($g,$s)use($d,$actor){$base=$this->baseline($g,$s);foreach(['billing_id','billing_version','service_id','service_version'] as $field)abort_unless($d[$field]===$base[$field],409,'Destino mudou; rever versões.');$last=DB::table('wc_requests')->where('generation_id',$g->id)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Pedido anterior mudou.');
|
||||
if($d['kind']==='withdraw')abort_unless($last&&!DB::table('wc_applications')->where('request_id',$last->id)->exists(),409,'Só desistir de pedido não aplicado.');
|
||||
if($d['kind']!=='replace')abort_unless($d['lines']===[],422,'Cancelamento/desistência não têm novas linhas.');$lines=$d['kind']==='replace'?$this->commercial($base,$d['lines'],$s):[];
|
||||
$snapshot=['policy'=>self::POLICY,'baseline'=>$base,'lines'=>$lines,'base_exact'=>$lines?DraftValues::base(['lines'=>$lines]):null,'reason'=>$d['reason']];$id=DB::table('wc_requests')->insertGetId(['generation_id'=>$g->id,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'kind'=>$d['kind'],'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];});
|
||||
}
|
||||
private function selected(int $generation,array $d): object {$r=DB::table('wc_requests')->where(['id'=>$d['id'],'version'=>$d['version'],'generation_id'=>$generation])->first()??abort(409,'Pedido/versão incompatível.');abort_if($r->kind==='withdraw'||DB::table('wc_requests')->where('previous_id',$r->id)->exists(),409,'Pedido desistido ou desactualizado.');return $r;}
|
||||
private function check(object $r,object $g,array $s): array {$snap=json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR);$base=$this->baseline($g,$s);abort_unless($this->canonical($snap['baseline'])===$this->canonical($base),409,'Origem, destino ou dependência mudou; nova revisão necessária.');if($r->kind==='replace'){$input=array_map(fn($l)=>['entry_id'=>$l['entry_id'],'service_id'=>$l['service_id'],'service_version'=>$l['service_version'],'price'=>$l['unit_price'],'price_source'=>$l['price_source'],'description'=>$l['description']],$snap['lines']);$this->commercial($base,$input,$s);}return $snap;}
|
||||
public function review(int $generation,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);return $this->cmd($generation,$actor,'review',$d,$key,function($g,$s)use($d,$actor){$r=$this->selected($g->id,$d);abort_if($r->actor_id===$actor,403,'Revisor distinto necessário.');$this->check($r,$g,$s);abort_if(DB::table('wc_reviews')->where('request_id',$r->id)->exists(),409,'Decisão imutável já registada.');$id=DB::table('wc_reviews')->insertGetId(['request_id'=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version];});
|
||||
}
|
||||
public function apply(int $generation,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']);return $this->cmd($generation,$actor,'apply',$d,$key,function($g,$s)use($d,$actor,$key){$r=$this->selected($g->id,$d);abort_unless(DB::table('wc_reviews')->where(['request_id'=>$r->id,'version'=>$r->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');$snap=$this->check($r,$g,$s);$base=$snap['baseline'];$service=null;$bill=null;
|
||||
if($r->kind==='replace'){$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Correcção oficina #'.$g->order_id.' · pedido #'.$r->id.' v'.$r->version;
|
||||
$service=$this->workflow->save('service_drafts',null,$actor,['name'=>$name,'client_id'=>$base['client_id'],'currency'=>'MZN','lines'=>$lines,'description'=>'Substituição rastreável; original preservado.'],substr(hash('sha256','wc-service-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
|
||||
$bill=$this->workflow->save('billing_drafts',null,$actor,['name'=>$name,'source_id'=>$service],substr(hash('sha256','wc-billing-'.$g->id.'-'.$r->id.'-'.$key),0,40))['id'];
|
||||
}
|
||||
$id=DB::table('wc_applications')->insertGetId(['generation_id'=>$g->id,'request_id'=>$r->id,'sequence'=>$base['sequence']+1,'kind'=>$r->kind,'old_service_id'=>$base['service_id'],'old_billing_id'=>$base['billing_id'],'new_service_id'=>$service,'new_billing_id'=>$bill,'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>$r->kind,'service_draft_id'=>$service,'billing_draft_id'=>$bill,'base_exact'=>$snap['base_exact']];});
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$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()];});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user