commit melhorias sgf
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use App\Http\Requests\Sgc\StorePaymentRequest;
|
||||
use App\Services\Sgc\IntegrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SgcIntegrationController extends Controller
|
||||
{
|
||||
private IntegrationService $integrationService;
|
||||
|
||||
public function __construct(IntegrationService $integrationService)
|
||||
{
|
||||
$this->integrationService = $integrationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/integration/sgc/invoices
|
||||
* Recebe fatura do SGC
|
||||
*/
|
||||
public function storeInvoice(StoreInvoiceRequest $request): JsonResponse
|
||||
{
|
||||
$idempotencyKey = $request->header('X-Idempotency-Key');
|
||||
|
||||
if (!$idempotencyKey) {
|
||||
return response()->json([
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'MISSING_IDEMPOTENCY_KEY',
|
||||
'message' => 'Header X-Idempotency-Key é obrigatório',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Verificar assinatura HMAC (em produção)
|
||||
// $this->verificarAssinatura($request);
|
||||
|
||||
$resultado = $this->integrationService->receberFatura(
|
||||
$request->validated(),
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
$statusCode = match($resultado['status']) {
|
||||
'ACCEPTED' => 201,
|
||||
'DUPLICATE' => 409,
|
||||
'REJECTED' => 400,
|
||||
default => 500,
|
||||
};
|
||||
|
||||
return response()->json($resultado, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/integration/sgc/payments
|
||||
* Recebe confirmação de pagamento do SGC
|
||||
*/
|
||||
public function storePayment(StorePaymentRequest $request): JsonResponse
|
||||
{
|
||||
$idempotencyKey = $request->header('X-Idempotency-Key');
|
||||
|
||||
if (!$idempotencyKey) {
|
||||
return response()->json([
|
||||
'status' => 'REJECTED',
|
||||
'error_code' => 'MISSING_IDEMPOTENCY_KEY',
|
||||
'message' => 'Header X-Idempotency-Key é obrigatório',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$resultado = $this->integrationService->receberPagamento(
|
||||
$request->validated(),
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
$statusCode = match($resultado['status']) {
|
||||
'ACCEPTED' => 201,
|
||||
'DUPLICATE' => 409,
|
||||
'REJECTED' => 400,
|
||||
default => 500,
|
||||
};
|
||||
|
||||
return response()->json($resultado, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/integration/sgc/invoices/{sgfDocNumber}/status
|
||||
* Consulta estado de uma fatura
|
||||
*/
|
||||
public function getInvoiceStatus(string $sgfDocNumber): JsonResponse
|
||||
{
|
||||
$fatura = \App\Models\Sgc\Fatura::where('sgf_doc_number', $sgfDocNumber)
|
||||
->with(['ordemCompra', 'fornecedor'])
|
||||
->first();
|
||||
|
||||
if (!$fatura) {
|
||||
return response()->json([
|
||||
'status' => 'NOT_FOUND',
|
||||
'message' => 'Fatura não encontrada',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Construir lifecycle
|
||||
$lifecycle = [
|
||||
['event' => 'RECEBIDA', 'at' => $fatura->created_at],
|
||||
];
|
||||
|
||||
if ($fatura->status_3_way_match !== 'Pendente') {
|
||||
$lifecycle[] = [
|
||||
'event' => '3WAY_MATCH',
|
||||
'at' => $fatura->updated_at,
|
||||
'result' => $fatura->status_3_way_match,
|
||||
];
|
||||
}
|
||||
|
||||
if ($fatura->status_pagamento === 'Pago') {
|
||||
$lifecycle[] = [
|
||||
'event' => 'PAGA',
|
||||
'at' => $fatura->data_pagamento,
|
||||
'reference' => $fatura->numero_comprovativo,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'sgf_doc_number' => $fatura->sgf_doc_number,
|
||||
'current_status' => $fatura->status_pagamento,
|
||||
'lifecycle' => $lifecycle,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/integration/sgc/stats
|
||||
* Estatísticas de integração
|
||||
*/
|
||||
public function getStats(): JsonResponse
|
||||
{
|
||||
$stats = [
|
||||
'faturas' => [
|
||||
'total' => \App\Models\Sgc\Fatura::count(),
|
||||
'conformes' => \App\Models\Sgc\Fatura::conformes()->count(),
|
||||
'com_excecao' => \App\Models\Sgc\Fatura::comExcecao()->count(),
|
||||
'pagas' => \App\Models\Sgc\Fatura::pagas()->count(),
|
||||
],
|
||||
'ordens_compra' => [
|
||||
'total' => \App\Models\Sgc\OrdemCompra::count(),
|
||||
'cabimentadas' => \App\Models\Sgc\OrdemCompra::cabimentadas()->count(),
|
||||
],
|
||||
'integracao' => [
|
||||
'aceites' => DB::table('sgc_integration_log')
|
||||
->where('status', 'ACCEPTED')->count(),
|
||||
'rejeitadas' => DB::table('sgc_integration_log')
|
||||
->where('status', 'REJECTED')->count(),
|
||||
'duplicadas' => DB::table('sgc_integration_log')
|
||||
->where('status', 'DUPLICATE')->count(),
|
||||
],
|
||||
];
|
||||
|
||||
return response()->json($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica assinatura HMAC (em produção)
|
||||
*/
|
||||
private function verificarAssinatura(Request $request): bool
|
||||
{
|
||||
$signature = $request->header('X-SGC-Signature');
|
||||
$secret = config('services.sgc.webhook_secret');
|
||||
|
||||
$payload = json_encode($request->all(), JSON_UNESCAPED_SLASHES);
|
||||
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
return hash_equals($expectedSignature, $signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\Sgc\StoreInvoiceRequest;
|
||||
use App\Modules\Purchasing\SgcInvoiceIngress;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
final class SgcInvoiceIngressController
|
||||
{
|
||||
public function context(int $order, \App\Modules\Purchasing\InvoiceController $controller, \App\Modules\Purchasing\ReceiptRuntime $runtime)
|
||||
{
|
||||
$state = $controller->show($order, $runtime)->getData(true);
|
||||
return response()->json(['order'=>$state['order'], 'revision'=>$state['revision'], 'receipt_progress'=>$state['receipt_progress']]);
|
||||
}
|
||||
|
||||
public function __invoke(StoreInvoiceRequest $request, SgcInvoiceIngress $ingress)
|
||||
{
|
||||
try { return $ingress->receive($request); }
|
||||
catch (QueryException $error) {
|
||||
if (in_array($error->getCode(), ['55P03','40P01','40001'], true)) {
|
||||
return response()->json(['error'=>'RETRY_SAME_KEY', 'message'=>'Operação concorrente; recuperar com a mesma chave e conteúdo.'], 503)->header('Retry-After', '1');
|
||||
}
|
||||
if ($error->getCode() === '23505') return response()->json(['error'=>'DOCUMENT_CONFLICT'], 409);
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Context\RequireModuleContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class AuthenticateSgcInvoice
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
abort_unless(config('sgc_invoice.enabled') === true, 404);
|
||||
// The installed native adapter is QA-only. This transport cannot remove that boundary.
|
||||
abort_unless(app()->environment('budget-testing'), 403);
|
||||
abort_unless($request->isSecure() || in_array($request->ip(), ['127.0.0.1', '::1'], true), 403);
|
||||
abort_if(strlen($request->getContent()) > 131072, 413);
|
||||
if ($request->isMethod('GET')) abort_if($request->query->count() || strlen($request->getContent()), 422);
|
||||
else abort_unless($request->isJson(), 415);
|
||||
$client = (string)$request->header('X-SGC-Client');
|
||||
$principals = config('sgc_invoice.principals', []);
|
||||
$principal = preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $client) ? ($principals[$client] ?? null) : null;
|
||||
$token = $request->bearerToken();
|
||||
abort_unless(is_array($principal) && ($principal['enabled'] ?? false) === true
|
||||
&& is_string($token) && strlen($token) >= 32 && strlen($token) <= 512
|
||||
&& preg_match('/^[a-f0-9]{64}$/D', $principal['token_sha256'] ?? '')
|
||||
&& hash_equals($principal['token_sha256'], hash('sha256', $token)), 401);
|
||||
foreach (['user_id', 'auth_version', 'tenant_id', 'company_id', 'unit_id'] as $key) {
|
||||
abort_unless(is_int($principal[$key] ?? null) && $principal[$key] > 0, 403);
|
||||
}
|
||||
$version = $principal['context_version'] ?? null;
|
||||
abort_unless(is_string($version) && preg_match('/^[a-f0-9]{64}$/D', $version)
|
||||
&& hash_equals($version, (string)$request->header('X-SGC-Context')), 409);
|
||||
$user = User::find($principal['user_id']);
|
||||
abort_unless($user && $user->auth_version === $principal['auth_version'], 401);
|
||||
$context = app(CompanyContext::class);
|
||||
$guard = Auth::getDefaultDriver();
|
||||
$oldUser = Auth::guard($guard)->user();
|
||||
$oldResolver = $request->getUserResolver();
|
||||
$oldSession = $request->hasSession() ? $request->session() : null;
|
||||
try {
|
||||
// Context and person come exclusively from the server-side principal, never JSON/cookies.
|
||||
$context->selectFor($user->id, $principal['tenant_id'], $principal['company_id'], $principal['unit_id']);
|
||||
Auth::guard($guard)->setUser($user);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$session = new Store('sgc_invoice_ephemeral', new ArraySessionHandler(10));
|
||||
$session->start();
|
||||
$session->put('company_context', ['tenant_id'=>$principal['tenant_id'], 'company_id'=>$principal['company_id'],
|
||||
'unit_id'=>$principal['unit_id'], 'module_id'=>'purchasing', 'token'=>$version]);
|
||||
$request->setLaravelSession($session);
|
||||
$request->headers->set('X-SGF-Context', $version);
|
||||
app(RequireModuleContext::class)->check($request, $context);
|
||||
$response = $next($request);
|
||||
$response->headers->set('Cache-Control', 'no-store, private');
|
||||
return $response;
|
||||
} finally {
|
||||
$context->clear();
|
||||
if (isset($session)) $session->flush();
|
||||
Auth::forgetGuards(); Auth::shouldUse($guard);
|
||||
if ($oldUser) Auth::guard($guard)->setUser($oldUser);
|
||||
$request->setUserResolver($oldResolver);
|
||||
if ($oldSession) $request->setLaravelSession($oldSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Sgc;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class StoreInvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return $this->user() !== null; }
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$base = ['phase'=>'required|in:preview,confirm', 'order_id'=>'required|integer|min:1'];
|
||||
if ($this->input('phase') === 'confirm') return $base + ['preview'=>'required|string|max:131072'];
|
||||
return $base + ['revision'=>'required|string|regex:/^[a-f0-9]{64}$/D', 'reason'=>'required|string|min:10|max:2000',
|
||||
'data'=>'required|array:number,issued_on,subtotal,tax,total,items', 'data.number'=>'required|string|max:80',
|
||||
'data.issued_on'=>'required|date_format:Y-m-d', 'data.subtotal'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.tax'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D', 'data.total'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.items'=>'required|array|min:1|max:50', 'data.items.*'=>'required|array:purchase_order_item_id,quantity,unit,unit_price,tax_rate',
|
||||
'data.items.*.purchase_order_item_id'=>'required|integer|min:1|distinct',
|
||||
'data.items.*.quantity'=>'required|string|regex:/^\d{1,12}(\.\d{1,3})?$/D',
|
||||
'data.items.*.unit'=>'required|string|max:20', 'data.items.*.unit_price'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
|
||||
'data.items.*.tax_rate'=>'required|string|regex:/^\d{1,3}(\.\d{1,2})?$/D'];
|
||||
}
|
||||
|
||||
public function after(): array
|
||||
{
|
||||
return [function ($validator) {
|
||||
$allowed = $this->input('phase') === 'confirm' ? ['phase','order_id','preview'] : ['phase','order_id','revision','reason','data'];
|
||||
if ($this->query->count() || array_diff(array_keys($this->json()->all()), $allowed)) $validator->errors()->add('payload', 'Campos ou parâmetros não permitidos.');
|
||||
if (!preg_match('/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[1-8][a-fA-F0-9]{3}-[89aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/D', (string)$this->header('X-Idempotency-Key'))) $validator->errors()->add('idempotency_key', 'X-Idempotency-Key UUID obrigatório.');
|
||||
}];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user