feat(settlement): add receipt handling and detail view for settlements
This commit is contained in:
@@ -17,6 +17,7 @@ import AppointmentDetailPage from './pages/AppointmentDetailPage';
|
||||
import PaymentsPage from './pages/PaymentsPage';
|
||||
import PaymentDetailPage from './pages/PaymentDetailPage';
|
||||
import SettlementsPage from './pages/SettlementsPage';
|
||||
import SettlementDetailPage from './pages/SettlementDetailPage';
|
||||
import RepresentationsPage from './pages/RepresentationsPage';
|
||||
import RepresentationDetailPage from './pages/RepresentationDetailPage';
|
||||
import CommentsPage from './pages/CommentsPage';
|
||||
@@ -147,6 +148,7 @@ export default function App() {
|
||||
<Route path="payments" element={<RoleRoute roles={['admin']}><PaymentsPage /></RoleRoute>} />
|
||||
<Route path="payments/:uuid" element={<RoleRoute roles={['admin']}><PaymentDetailPage /></RoleRoute>} />
|
||||
<Route path="settlements" element={<RoleRoute roles={['admin']}><SettlementsPage /></RoleRoute>} />
|
||||
<Route path="settlements/:uuid" element={<RoleRoute roles={['admin']}><SettlementDetailPage /></RoleRoute>} />
|
||||
<Route path="financial-report" element={<RoleRoute roles={['admin']}><FinancialReportPage /></RoleRoute>} />
|
||||
<Route path="representations" element={<RoleRoute roles={['admin']}><RepresentationsPage /></RoleRoute>} />
|
||||
<Route path="representations/:uuid" element={<RoleRoute roles={['admin']}><RepresentationDetailPage /></RoleRoute>} />
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRightIcon, CheckIcon, XMarkIcon, ArrowUpTrayIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { formatDateTime, formatRial } from '../lib/utils';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
|
||||
interface SettlementDetail {
|
||||
uuid: string;
|
||||
representation_name: string;
|
||||
representation_mobile: string | null;
|
||||
amount: number;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'paid';
|
||||
bank_card: string | null;
|
||||
bank_name: string | null;
|
||||
bank_iban: string | null;
|
||||
bank_owner: string | null;
|
||||
reject_reason: string | null;
|
||||
receipt: string | null;
|
||||
requested_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: '1px solid var(--border)', gap: 12 }}>
|
||||
<span className="muted" style={{ fontSize: 13 }}>{label}</span>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 500, textAlign: 'left' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettlementDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const authToken = useAuthStore(s => s.token);
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [paying, setPaying] = useState(false);
|
||||
const receiptInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['settlement-detail', uuid],
|
||||
queryFn: () => api.get<ApiResponse<SettlementDetail>>(`/api/v1/admin/settlement/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s: SettlementDetail | undefined = (data?.data as any)?.data ?? data?.data;
|
||||
|
||||
const approveMut = useMutation({
|
||||
mutationFn: () => api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('تسویه تأیید شد');
|
||||
setApproveOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const rejectMut = useMutation({
|
||||
mutationFn: () => api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/reject`, { reason: rejectReason }),
|
||||
onSuccess: () => {
|
||||
toast.success('تسویه رد شد');
|
||||
setRejectOpen(false);
|
||||
setRejectReason('');
|
||||
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// آپلود رسید → سپس ثبت پرداخت نهایی (paid). مبلغ قبلاً هنگام درخواست از کیفپول کسر شده.
|
||||
const handleReceiptUpload = async (file: File) => {
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await fetch('/file/upload/clinic_pro/settlement/receipt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Disposition': `filename="${file.name}"`,
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
Authorization: `Bearer ${authToken ?? ''}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
const json = await res.json();
|
||||
const url = json?.data?.url;
|
||||
if (!url) throw new Error('آپلود رسید ناموفق بود');
|
||||
await api.post<ApiResponse<null>>(`/api/v1/settlement/${uuid}/paid`, { receipt: url });
|
||||
toast.success('پرداخت ثبت شد');
|
||||
qc.invalidateQueries({ queryKey: ['settlement-detail', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) { toast.error(e?.message ?? 'خطا در ثبت پرداخت'); }
|
||||
finally { setPaying(false); }
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card card-pad"><div className="skeleton" style={{ height: 300, borderRadius: 'var(--r)' }} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card card-pad" style={{ textAlign: 'center', padding: 40 }}>
|
||||
<p className="muted">درخواست تسویه یافت نشد</p>
|
||||
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => navigate('/admin/settlements')}>
|
||||
بازگشت به لیست
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button className="btn ghost sm" onClick={() => navigate('/admin/settlements')} style={{ padding: '6px 10px' }}>
|
||||
<ArrowRightIcon style={{ width: 15, height: 15 }} /> تسویهحسابها
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="section-title">جزئیات تسویه</h1>
|
||||
<div className="muted" style={{ fontSize: 13 }}>{s.representation_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
{s.status === 'pending' && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary sm" onClick={() => setApproveOpen(true)}>
|
||||
<CheckIcon style={{ width: 15, height: 15 }} /> تأیید
|
||||
</button>
|
||||
<button className="btn danger sm" onClick={() => setRejectOpen(true)}>
|
||||
<XMarkIcon style={{ width: 15, height: 15 }} /> رد
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ maxWidth: 560 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--primary)' }}>{formatRial(s.amount)}</span>
|
||||
<StatusBadge type="settlement" value={s.status} />
|
||||
</div>
|
||||
<Row label="نماینده" value={s.representation_name} />
|
||||
<Row label="موبایل" value={s.representation_mobile ? <span dir="ltr">{s.representation_mobile}</span> : '—'} />
|
||||
<Row label="صاحب حساب" value={s.bank_owner ?? '—'} />
|
||||
<Row label="بانک" value={s.bank_name ?? '—'} />
|
||||
<Row label="شماره کارت" value={s.bank_card ? <span dir="ltr" style={{ fontFamily: 'monospace' }}>{s.bank_card}</span> : '—'} />
|
||||
<Row label="شبا" value={s.bank_iban ? <span dir="ltr" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.bank_iban}</span> : '—'} />
|
||||
<Row label="تاریخ درخواست" value={formatDateTime(s.requested_at)} />
|
||||
<Row label="تاریخ بررسی" value={s.processed_at ? formatDateTime(s.processed_at) : '—'} />
|
||||
{s.reject_reason && <Row label="یادداشت/دلیل رد" value={s.reject_reason} />}
|
||||
</div>
|
||||
|
||||
{/* رسید پرداخت */}
|
||||
{(s.status === 'approved' || s.status === 'paid' || s.receipt) && (
|
||||
<div className="card card-pad" style={{ maxWidth: 560, marginTop: 'var(--gap)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<DocumentTextIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
|
||||
<b style={{ fontSize: 14 }}>رسید پرداخت</b>
|
||||
</div>
|
||||
|
||||
{s.receipt ? (
|
||||
<a href={s.receipt} target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'block', maxWidth: 280 }}>
|
||||
<img src={s.receipt} alt="رسید"
|
||||
style={{ width: '100%', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }} />
|
||||
<span className="muted" style={{ fontSize: 12, display: 'block', marginTop: 6, color: 'var(--primary)' }}>
|
||||
مشاهدهی اندازهی کامل ↗
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<p className="muted" style={{ fontSize: 13 }}>هنوز رسیدی آپلود نشده است.</p>
|
||||
)}
|
||||
|
||||
{s.status === 'approved' && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<input ref={receiptInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={e => e.target.files?.[0] && handleReceiptUpload(e.target.files[0])} />
|
||||
<button className="btn primary sm" disabled={paying}
|
||||
onClick={() => receiptInputRef.current?.click()}>
|
||||
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
|
||||
{paying ? 'در حال ثبت...' : 'آپلود رسید و ثبت پرداخت'}
|
||||
</button>
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||
با آپلود رسید، وضعیت به «پرداخت شده» تغییر میکند. مبلغ هنگام ثبت درخواست از کیفپول کسر شده است.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={approveOpen}
|
||||
title="تأیید تسویه"
|
||||
message={`تسویه ${s.representation_name} به مبلغ ${formatRial(s.amount)} را تأیید میکنید؟`}
|
||||
confirmLabel="تأیید"
|
||||
loading={approveMut.isPending}
|
||||
onConfirm={() => approveMut.mutate()}
|
||||
onCancel={() => setApproveOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={rejectOpen}
|
||||
title="رد درخواست تسویه"
|
||||
onClose={() => { setRejectOpen(false); setRejectReason(''); }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setRejectOpen(false); setRejectReason(''); }} className="btn ghost sm">لغو</button>
|
||||
<button onClick={() => rejectMut.mutate()} disabled={!rejectReason || rejectMut.isPending} className="btn danger sm">
|
||||
{rejectMut.isPending ? 'در حال ارسال...' : 'رد کردن'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label>دلیل رد</label>
|
||||
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} rows={4}
|
||||
placeholder="دلیل رد درخواست را بنویسید..." className="input" style={{ resize: 'none', height: 'auto' }} />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const STATUS_FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'approved', label: 'تأیید شده' },
|
||||
{ value: 'paid', label: 'پرداخت شده' },
|
||||
{ value: 'rejected', label: 'رد شده' },
|
||||
];
|
||||
|
||||
|
||||
@@ -519,8 +519,7 @@ table.t tbody tr:last-child td { border-bottom: none; }
|
||||
.cell-user { display: flex; align-items: center; gap: 11px; }
|
||||
.cell-user b { font-weight: 700; font-size: 13.5px; color: var(--text); }
|
||||
.cell-user small { color: var(--text-3); font-size: 11.5px; }
|
||||
.row-actions { display: flex; gap: 6px; opacity: 0; transition: opacity .15s; }
|
||||
table.t tbody tr:hover .row-actions { opacity: 1; }
|
||||
.row-actions { display: flex; gap: 6px; opacity: 1; transition: opacity .15s; }
|
||||
.mini-btn {
|
||||
width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center;
|
||||
color: var(--text-3); transition: .14s; cursor: pointer; border: none; background: none;
|
||||
|
||||
@@ -27,6 +27,10 @@ services:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Settlement\Controller\SettlementController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\UserProfile\Controller\UserProfileController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -1052,3 +1052,27 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/v1/admin/settlement/{uuid}`
|
||||
|
||||
جزئیات یک درخواست تسویه (برای صفحهی `/admin/settlements/{uuid}`). **Permission:** `ROLE_ADMIN`
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"representation_name": "نماینده یزد",
|
||||
"representation_mobile": "09390036732",
|
||||
"amount": 500000,
|
||||
"status": "pending",
|
||||
"bank_card": "6037...", "bank_name": "ملت", "bank_iban": "IR...", "bank_owner": "...",
|
||||
"reject_reason": null,
|
||||
"requested_at": "2026-06-24T...", "processed_at": null
|
||||
}
|
||||
}
|
||||
```
|
||||
تأیید/رد از طریق `POST /api/v1/settlement/{uuid}/approve|reject` (در `docs/api/settlement.md`).
|
||||
|
||||
**Errors:** `NOT_FOUND` (404) — درخواست یافت نشد.
|
||||
|
||||
@@ -258,3 +258,39 @@ Updated settlement object with `status: "rejected"`.
|
||||
## FinancialBreakdown (لاگ مالی)
|
||||
|
||||
علاوه بر تسویهحساب دستی، کیفپول نماینده بهصورت خودکار از طریق `CommissionService` هنگام پرداخت موفقِ نوبت/اشتراک شارژ میشود (`WalletTransaction` credit). هر واریز یک ردیف `FinancialBreakdown` ثبت میکند که تفکیک کامل تراکنش (ناخالص، هزینه پیامک، مالیات، خالص، درصد و سهم پورسانت، سهم سیستم) را نگه میدارد. ثبت idempotent است (بر اساس `payment_id`). گزارشها از طریق `GET /api/v1/admin/financial-breakdowns` و `GET /api/v1/admin/financial-summary` در دسترساند — جزئیات در `docs/api/admin.md`.
|
||||
|
||||
---
|
||||
|
||||
## رسید پرداخت و ثبت پرداخت نهایی (ROLE_ADMIN)
|
||||
|
||||
> منطق کیفپول: مبلغ هنگام **ثبت درخواست** از کیفپول نماینده کسر (reserve/debit) میشود؛ **رد** آن را برمیگرداند (credit). تأیید و پرداختِ نهایی کیفپول را دوباره دست نمیزنند (جلوگیری از double-debit).
|
||||
|
||||
### POST `/file/upload/clinic_pro/settlement/receipt`
|
||||
|
||||
آپلود تصویر رسید پرداخت. بدنه = محتوای خام فایل؛ هدر `Content-Disposition: filename="..."`. **Permission:** `ROLE_ADMIN`
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "success": true, "data": { "uuid": "...", "url": "/uploads/settlements/receipts/2026-06/...", "filename": "...", "filemime": "image/jpeg" } }
|
||||
```
|
||||
|
||||
### POST `/api/v1/settlement/{uuid}/paid`
|
||||
|
||||
ثبت پرداخت نهایی با رسید. فقط روی تسویهی **approved**. وضعیت → `paid` و `receipt` ذخیره میشود. کیفپول تغییر نمیکند. **Permission:** `ROLE_ADMIN`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{ "receipt": "/uploads/settlements/receipts/2026-06/..." }
|
||||
```
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `receipt` | string | ✅ | URL رسیدِ آپلودشده |
|
||||
|
||||
**Response `200`:** آبجکت تسویه (شامل `receipt` و `status: "paid"`).
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | درخواست یافت نشد |
|
||||
| `ERR_VALIDATION_001` | 422 | تسویه approved نیست |
|
||||
| `ERR_VALIDATION_002` | 422 | `receipt` خالی (`field: receipt`) |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260625135132 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE settlements ADD receipt VARCHAR(500) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE settlements DROP receipt');
|
||||
}
|
||||
}
|
||||
@@ -1495,6 +1495,52 @@ class AdminApiController extends BaseController
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/settlement/{uuid}',
|
||||
summary: 'جزئیات یک درخواست تسویه (ادمین)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'جزئیات تسویه'),
|
||||
new OA\Response(response: 404, description: 'یافت نشد'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/settlement/{uuid}', methods: ['GET'])]
|
||||
public function settlementDetail(string $uuid): JsonResponse
|
||||
{
|
||||
$row = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
's.uuid, s.amountRials, s.status, s.bankAccount, s.adminNote, s.receipt, s.reviewedAt, s.createdAt, s.updatedAt',
|
||||
'u.realName as user_name, u.mobileNumber as user_mobile',
|
||||
)
|
||||
->from(Settlement::class, 's')
|
||||
->join('s.user', 'u')
|
||||
->where('s.uuid = :uuid')
|
||||
->setParameter('uuid', $uuid)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
if (empty($row)) {
|
||||
return $this->error('NOT_FOUND', 'درخواست تسویه یافت نشد', 404);
|
||||
}
|
||||
$s = $row[0];
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $s['uuid'],
|
||||
'representation_name' => $s['user_name'] ?? $s['user_mobile'],
|
||||
'representation_mobile' => $s['user_mobile'],
|
||||
'amount' => (int) $s['amountRials'],
|
||||
'status' => $s['status'],
|
||||
'bank_card' => $s['bankAccount']['card'] ?? null,
|
||||
'bank_name' => $s['bankAccount']['bank_name'] ?? null,
|
||||
'bank_iban' => $s['bankAccount']['iban'] ?? null,
|
||||
'bank_owner' => $s['bankAccount']['owner_name'] ?? null,
|
||||
'reject_reason' => $s['adminNote'],
|
||||
'receipt' => $s['receipt'] ?? null,
|
||||
'requested_at' => date('c', (int) $s['createdAt']),
|
||||
'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── SMS Templates (paginated list with optional status filter) ────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[OA\Tag(name: 'Settlements')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
@@ -23,6 +24,8 @@ class SettlementController extends BaseController
|
||||
public function __construct(
|
||||
private readonly SettlementRepository $settlementRepo,
|
||||
private readonly WalletTransactionRepository $walletRepo,
|
||||
private readonly \App\Shared\Service\FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
// ── Wallet ────────────────────────────────────────────────────────────────
|
||||
@@ -339,4 +342,97 @@ class SettlementController extends BaseController
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/file/upload/clinic_pro/settlement/receipt',
|
||||
summary: 'آپلود رسید پرداخت تسویه (ROLE_ADMIN)',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'فایل آپلود شد')]
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/file/upload/clinic_pro/settlement/receipt', methods: ['POST'])]
|
||||
public function uploadReceipt(Request $request): JsonResponse
|
||||
{
|
||||
return $this->handleFileUpload($request, 'settlements/receipts');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/settlement/{uuid}/paid',
|
||||
summary: 'ثبت پرداخت نهایی تسویه با رسید (ROLE_ADMIN)',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['receipt'],
|
||||
properties: [new OA\Property(property: 'receipt', type: 'string', description: 'URL رسید آپلودشده')]
|
||||
)
|
||||
),
|
||||
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'پرداخت ثبت شد'),
|
||||
new OA\Response(response: 404, description: 'یافت نشد'),
|
||||
new OA\Response(response: 422, description: 'وضعیت نامعتبر یا رسید خالی'),
|
||||
]
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/settlement/{uuid}/paid', methods: ['POST'])]
|
||||
public function markPaid(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$settlement = $this->settlementRepo->findByUuid($uuid);
|
||||
if ($settlement === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
|
||||
}
|
||||
|
||||
// فقط درخواستِ تأییدشده قابل پرداخت است.
|
||||
if ($settlement->getStatus() !== Settlement::STATUS_APPROVED) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فقط تسویهی تأییدشده قابل پرداخت است', 422);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$receipt = trim((string) ($data['receipt'] ?? ''));
|
||||
if ($receipt === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'رسید پرداخت الزامی است', 422, 'receipt');
|
||||
}
|
||||
|
||||
// مبلغ هنگام ثبتِ درخواست از کیفپول کسر (reserve) شده؛ اینجا فقط نهاییسازی میشود.
|
||||
$settlement->markPaid($receipt);
|
||||
$this->settlementRepo->save($settlement);
|
||||
|
||||
return $this->success(['data' => $settlement->toArray()]);
|
||||
}
|
||||
|
||||
private function handleFileUpload(Request $request, string $subDir): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$disposition = $request->headers->get('Content-Disposition', '');
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m);
|
||||
$filename = $m[1] ?? 'receipt.jpg';
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y'); $month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
rename($tmpPath, $dir . '/' . $storedName);
|
||||
|
||||
$url = '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName;
|
||||
|
||||
return $this->success([
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'url' => $url,
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) unlink($tmpPath);
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ class Settlement
|
||||
#[ORM\Column(name: 'admin_note', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $adminNote = null;
|
||||
|
||||
#[ORM\Column(name: 'receipt', type: 'string', length: 500, nullable: true)]
|
||||
private ?string $receipt = null;
|
||||
|
||||
#[ORM\Column(name: 'reviewed_by', type: 'integer', nullable: true)]
|
||||
private ?int $reviewedBy = null;
|
||||
|
||||
@@ -69,6 +72,7 @@ class Settlement
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getBankAccount(): ?array { return $this->bankAccount; }
|
||||
public function getAdminNote(): ?string { return $this->adminNote; }
|
||||
public function getReceipt(): ?string { return $this->receipt; }
|
||||
|
||||
public function approve(int $adminUserId, ?string $note = null): self
|
||||
{
|
||||
@@ -90,9 +94,10 @@ class Settlement
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPaid(): self
|
||||
public function markPaid(?string $receipt = null): self
|
||||
{
|
||||
$this->status = self::STATUS_PAID;
|
||||
if ($receipt !== null) $this->receipt = $receipt;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
@@ -105,6 +110,7 @@ class Settlement
|
||||
'status' => $this->status,
|
||||
'bank_account' => $this->bankAccount,
|
||||
'admin_note' => $this->adminNote,
|
||||
'receipt' => $this->receipt,
|
||||
'reviewed_at' => $this->reviewedAt,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user