Files
portal/app/app/Http/Controllers/Api/Sales/SalesInvoiceController.php
T
Дмитрий 72f6a6ae89 feat(sales): счета — список + отметка оплаты (бэк, переиспользование)
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>
2026-07-02 20:38:33 +03:00

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;
}
}