72f6a6ae89
Task 5.1a — SalesInvoiceController (index, markPaid), только head. - index: список saas_invoices (структура 1:1 с AdminInvoiceController — фильтры status/search, пагинация). - markPaid: делегирует InvoicePaymentService::markPaid (идемпотентный claim issued→paid + зачисление баланса + акт + письмо) — логика НЕ дублируется. Head-гейт перед обоими методами (менеджер → 403 до обращения к сервису). Тесты 7/7 (вкл. пополнение баланса и идемпотентность повторной отметки), sales-набор 158/158, Larastan 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
4.2 KiB
PHP
111 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\Sales;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\SalesUser;
|
|
use App\Services\Billing\Invoice\InvoicePaymentService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Портал продаж — счета клиентов + ручная отметка оплаты (Task 5.1a).
|
|
*
|
|
* GET /api/sales/invoices — список счетов (saas_invoices + tenant)
|
|
* POST /api/sales/invoices/{id}/mark-paid — отметить счёт оплаченным
|
|
*
|
|
* ОБА метода — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
|
|
*
|
|
* Зачисление НЕ дублируется: делегируется InvoicePaymentService::markPaid
|
|
* (идемпотентный claim issued→paid + зачисление баланса BillingTopupService +
|
|
* акт + письмо клиенту, под tenant RLS-контекстом). Мимикрирует AdminInvoiceController,
|
|
* добавляя head-гейт зоны продаж.
|
|
*
|
|
* Spec: Task 5.1a бэк — SalesInvoiceController.
|
|
*/
|
|
class SalesInvoiceController extends Controller
|
|
{
|
|
public function __construct(private readonly InvoicePaymentService $payments) {}
|
|
|
|
/**
|
|
* GET /api/sales/invoices — список счетов (только head).
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
|
return $resp;
|
|
}
|
|
|
|
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
|
|
|
|
$query = DB::table('saas_invoices as i')
|
|
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
|
|
->select(
|
|
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
|
|
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
|
|
);
|
|
|
|
$status = $request->query('status');
|
|
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
|
|
$query->where('i.status', $status);
|
|
}
|
|
|
|
$search = trim((string) $request->query('search', ''));
|
|
if ($search !== '') {
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('i.invoice_number', 'ilike', "%{$search}%")
|
|
->orWhere('i.payer_name', 'ilike', "%{$search}%")
|
|
->orWhere('t.organization_name', 'ilike', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
|
|
|
|
return response()->json([
|
|
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
|
|
'meta' => [
|
|
'current_page' => $page->currentPage(),
|
|
'last_page' => $page->lastPage(),
|
|
'total' => $page->total(),
|
|
'per_page' => $page->perPage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/sales/invoices/{id}/mark-paid — отметить счёт оплаченным (только head).
|
|
*
|
|
* Зачисление делегируется InvoicePaymentService (идемпотентно, под RLS).
|
|
*/
|
|
public function markPaid(Request $request, int $id): JsonResponse
|
|
{
|
|
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
|
return $resp;
|
|
}
|
|
|
|
$this->payments->markPaid($id);
|
|
|
|
return response()->json(['status' => 'ok']);
|
|
}
|
|
|
|
// ── private ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
|
|
*/
|
|
private function denyIfNotHead(Request $request): ?JsonResponse
|
|
{
|
|
/** @var SalesUser $user */
|
|
$user = $request->user('sales');
|
|
|
|
if (! $user->isHead()) {
|
|
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|