# گزارش‌های قابل تنظیم — نسخه ۳

این نسخه ۵ گزارش قابل تنظیم دارد. همه POSTها فقط خواندنی‌اند و هیچ سند مالی نمی‌سازند. برای همه آن‌ها توکن لازم است ولی Idempotency-Key لازم نیست. نقش‌های ADMIN، CASHIER و VIEWER مطابق سیاست نسخه قبل اجازه خواندن اطلاعات مالی و سود دارند؛ مخفی‌کردن ستون در فرانت، کنترل دسترسی نیست.

| گزارش | درخواست | کشف ستون و فیلتر |
|---|---|---|
| معاملات | POST /api/reports/trades | GET /api/reports/trades/options |
| دریافت و پرداخت | POST /api/reports/payments | GET /api/reports/payments/options |
| گردش یک مشتری | POST /api/reports/statement | GET /api/reports/statement/options |
| مانده مشتریان | POST /api/reports/balances | GET /api/reports/balances/options |
| سود مشتریان | POST /api/reports/customer-profit | GET /api/reports/customer-profit/options |

GET /api/reports/catalog فهرست همه گزارش‌های جدید را برمی‌گرداند. گزینه‌ها در docs/report-options.json هم موجودند. GETهای نسخه قبل برقرارند؛ فقط POSTهای جدول بالا قرارداد جدید دارند.

## درخواست

```json
{
  "search": "احمد",
  "filters": {
    "customerId": 12,
    "currency": "USD",
    "status": "POSTED",
    "from": "2026-09-01T00:00:00Z",
    "to": "2026-10-01T00:00:00Z"
  },
  "fields": ["id", "customerName", "receivedAmount", "receivedCurrency", "feeEur"],
  "sort": {"field": "createdAt", "direction": "desc"},
  "page": 1,
  "limit": 25
}
```

این مثال برای trades است. هر گزارش فقط فیلترهای خودش را می‌پذیرد؛ مثلاً balances فیلتر status یا from ندارد. مقدار fields از کلیدهای مجاز انتخاب می‌شود؛ ترتیب ستون‌ها در columns همان ترتیب درخواست است. اگر fields نفرستید، defaultFields اعمال می‌شود. حداقل یک فیلد لازم است و تکرار فیلد پذیرفته نیست. فیلدهایی مثل passwordHash و requestHash قابل انتخاب نیستند.

search فقط در نام، کد و تلفن مشتری جست‌وجو می‌کند و همراه سایر فیلترها AND می‌شود. فاصله ابتدا و انتها حذف می‌شود؛ حداکثر ۱۵۰ کاراکتر است. علامت‌های % و _ به‌صورت متن واقعی جست‌وجو می‌شوند، نه wildcard دلخواه SQL. تطبیق حروف تابع collation دیتابیس است؛ تبدیل خودکار ی/ي یا ک/ك نداریم.

page از ۱ شروع می‌شود. limit از ۱ تا ۱۰۰ است و پیش‌فرض آن ۲۵ است. offset بیشتر از ۱۰۰هزار رد می‌شود و باید فیلتر محدودتر شود. جهت sort فقط asc یا desc کوچک است. فقط فیلد دارای sortable=true پذیرفته می‌شود. سرور در مرتب‌سازی‌های مساوی کلید یکتا را اضافه می‌کند تا بین دو صفحه ردیف‌ها بی‌دلیل جابه‌جا نشوند. بین درخواست‌های جداگانه با ورود داده جدید، pagination مبتنی بر offset ممکن است جابه‌جایی داشته باشد؛ snapshot چندصفحه‌ای ثابت نداریم.

تمام مبالغ و نرخ‌ها رشته Decimal هستند. page، limit و customerId عددند؛ customerActive بولین است. dates با ISO UTC مانند 2026-09-01T00:00:00Z ارسال می‌شوند. from شامل ابتدای بازه و to غیرشامل انتهای بازه است؛ برای گزارش یک روز، from ابتدای روز و to ابتدای روز بعد است.

## پاسخ

```json
{
  "data": [
    {"id": "example-uuid", "customerName": "احمد", "feeEur": "4.6"}
  ],
  "meta": {
    "report": "trades",
    "page": 1,
    "limit": 1,
    "totalItems": 83,
    "totalPages": 83,
    "hasNextPage": true,
    "hasPreviousPage": false,
    "outOfRange": false,
    "baseCurrency": "EUR",
    "timezone": "UTC",
    "generatedAt": "2026-09-22T12:00:00.000Z",
    "filters": {"status": "POSTED"},
    "search": "",
    "sort": {"field": "createdAt", "direction": "desc"},
    "fields": ["id", "customerName", "feeEur"],
    "valuation": "Document snapshots"
  },
  "summary": {
    "scope": "allFilteredResults",
    "financialStatus": "POSTED",
    "tradeCount": 83,
    "postedTradeCount": 83,
    "feeRevenueEur": "250",
    "knownExchangeProfitEur": "600",
    "unknownProfitTrades": 3,
    "knownSubtotalEur": "850",
    "totalProfitEur": null,
    "byCurrency": []
  },
  "columns": [
    {"key": "id", "label": "شناسه", "type": "string", "sortable": true},
    {"key": "customerName", "label": "نام مشتری", "type": "string", "sortable": true},
    {"key": "feeEur", "label": "کارمزد یورویی", "type": "decimal", "sortable": true, "currency": "EUR"}
  ]
}
```

نمونه بالا برای توضیح قرارداد است و داده واقعی نیست؛ توضیح basis نیز در summary سود بازمی‌گردد. پاسخ جدید مستقیم است: report.data آرایه ردیف‌هاست، نه report.data.data. APIهای قدیمی همچنان envelope قدیمی خود را دارند.

totalItems تعداد کل ردیف‌ها بعد از اعمال همه فیلترها و قبل از LIMIT است. در صفحه خارج از محدوده data خالی است و summary کل گزارش حفظ می‌شود. اگر هیچ نتیجه‌ای نباشد، totalPages صفر است و دکمه قبل/بعد باید غیرفعال باشد.

summary از تمام نتایج فیلترشده محاسبه می‌شود؛ تغییر fields یا page جمع کل را تغییر نمی‌دهد. بک‌اند rows، totalItems و summary را در یک تراکنش RepeatableRead می‌خواند تا وسط تولید همان پاسخ، ثبت هم‌زمان سند باعث ناسازگاری عددها نشود.

## قواعد هر گزارش

### معاملات

currency در حالت عادی یعنی ارز دریافتی یا پرداختی معامله. minAmount/maxAmount همیشه روی receivedAmount هستند؛ بنابراین همراه این دو فیلتر، currency الزامی است و به‌عنوان ارز دریافتی محدود می‌شود. آستانه مبلغ دلار با یورو مخلوط نمی‌شود.

بدون status ردیف‌های POSTED و VOID هر دو ممکن است دیده شوند. tradeCount تعداد همه ردیف‌های منطبق و postedTradeCount تعداد اسناد فعال است. جمع‌های مالی فقط POSTED را حساب می‌کنند؛ اگر فقط VOID را فیلتر کنید، جمع مالی صفر است. byCurrency اصل دریافتی و کارمزد را به تفکیک ارز دریافتی جمع می‌کند؛ جمع مستقیم چندارزی ارائه نمی‌شود.

### دریافت و پرداخت

direction یکی از RECEIVE و PAY است. tradeId فقط پرداخت‌هایی را می‌آورد که صریحاً به آن معامله ارجاع داده‌اند. summary.byCurrency شامل receivedAmount، paidAmount و netCashMovement است؛ فقط اسناد فعال در جمع هستند. netCashMovement برابر دریافت منهای پرداخت است، نه تغییر حساب مشتری.

### گردش حساب

filters.customerId الزامی است. هر ردیف یک LedgerEntry است و source می‌تواند TRADE، PAYMENT، OPENING یا REVERSAL باشد. ردیف معکوس حذف نمی‌شود؛ برای فهم حساب باید همراه سابقه اصلی قابل مشاهده بماند.

runningBalance بر اساس تمام گردش‌های همان مشتری و ارز، به ترتیب createdAt و id، قبل از فیلتر ردیف‌ها و صفحه‌بندی محاسبه می‌شود. اگر فقط PAYMENT را انتخاب کنید، مانده همچنان تعهد معامله و افتتاحیه‌های قبلی را در خود دارد. مرتب‌سازی نمایشی نزولی نیز مفهوم تاریخی مانده را تغییر نمی‌دهد.

summary.byCurrency فقط حرکت ردیف‌های منطبق با فیلتر را جمع می‌کند. summary.openingBeforePeriod مانده پیش از from است و فیلتر source و minAmount/maxAmount را نادیده می‌گیرد؛ آن فیلترها نباید سابقه حساب را عوض کنند. اگر from ندارید افتتاحیه بازه آرایه خالی است. این دو جمع در فیلترهای جزئی الزاماً جمع‌شدنی برای محاسبه مانده نهایی نیستند؛ runningBalance مرجع مانده هر ردیف است.

### مانده مشتریان

هر ردیف یک زوج مشتری/ارز است، نه یک مشتری یا یک معامله. مانده مثبت CUSTOMER_OWES، منفی CUSTOMER_IS_OWED و صفر SETTLED است. مشتری بدون هیچ گردش، ردیفی ندارد. summary بدهی مثبت مشتری‌ها و طلب آن‌ها را جدا جمع می‌کند تا بدهی یک نفر با طلب نفر دیگر پنهان نشود.

asOf اختیاری است و گردش‌های قبل از زمان مشخص‌شده را جمع می‌کند. نرخ eurEquivalent همواره آخرین نرخ فعلی ذخیره‌شده است، حتی برای asOf قدیمی؛ گزارش ارزش‌گذاری تاریخی نیست. نبود نرخ به null منجر می‌شود و missingRateRows تعداد موارد ناقص را اعلام می‌کند.

### سود مشتریان

هر ردیف یک مشتری با حداقل یک معامله POSTED منطبق با فیلتر است. currency روی ارز دریافتی یا پرداختی معامله و بازه تاریخ روی زمان ثبت معامله اعمال می‌شود. تعداد کل صفحات بر اساس تعداد مشتری‌های منطبق است، نه تعداد معاملات.

feeRevenueEur کارمزد ثبت‌شده، knownExchangeProfitEur سود با هزینه مشخص، knownSubtotalEur جمع این دو و unknownProfitTrades تعداد معاملات با هزینه نامشخص است. اگر یکی از هزینه‌ها نامشخص باشد totalProfitEur همان مشتری و گزارش کلی null خواهد بود. کارمزد ثبت‌شده لزوماً وصول نشده و سود بر پایه نرخ سند است؛ هزینه‌های عملیاتی یا سود تسعیر در این گزارش نیستند. هنگام sort روی totalProfitEur مقدارهای null تابع ترتیب استاندارد MySQL هستند: در ASC ابتدا و در DESC انتها.

## فرانت چگونه مصرف کند؟

ابتدا catalog و سپس options گزارش انتخابی را بگیرید. از fields، filters و defaultSort فرم انتخاب بسازید. مبلغ Decimal را در جاوااسکریپت به Number تبدیل نکنید؛ برای نمایش از فرمت‌کننده Decimal استفاده کنید. currencyField یعنی ارز از فیلد همان ردیف خوانده می‌شود؛ هنگام انتخاب مبلغ ارزی، فیلد ارز مربوط را هم درخواست کنید.

با تغییر فیلتر یا جست‌وجو page را به ۱ برگردانید. برای جست‌وجو debounce کوتاه بگذارید و درخواست قبلی را لغو کنید. summary را در کارت‌های بالای جدول نشان دهید و meta را برای کنترل صفحه‌ها استفاده کنید. مقدار null را «نامشخص» نشان دهید، نه صفر.

## امنیت، خطا و محدودیت

فیلد و sort از فهرست ثابت انتخاب می‌شوند؛ هیچ نام جدول یا SQL از درخواست قبول نمی‌شود. مقادیر جست‌وجو و فیلتر به‌صورت پارامتر SQL ارسال می‌شوند. کلید اضافی، فیلتر نامجاز، بازه معکوس، field تکراری و مقدار عددی نامعتبر 400 دارند؛ نشست نامعتبر 401، نقش نامجاز 403، روت ناشناخته 404 و timeout دیتابیس 503 است. پاسخ API دارای Cache-Control: no-store است.

گزارش‌ها read-only هستند ولی MySQL می‌تواند برای تجمیع بزرگ کار قابل توجهی انجام دهد. محدودیت صفحه و timeout جایگزین طراحی مقیاس بسیار بزرگ نیست. تراکنش گزارش ۳۰ ثانیه مهلت دارد و query ردیف/تعداد، راهنمای MAX_EXECUTION_TIME برابر ۱۵ ثانیه دارد. نسخه ۴ خروجی CSV و تنظیم گزارش شخصی دارد؛ راهنمای آن در OPERATIONS_V4_FA.md است. XLSX، pivot و گروه‌بندی آزاد پیاده‌سازی نشده‌اند.

در نسخه ۴ ستون documentNumber و جست‌وجوی شماره سند، profitStatus برای مشخص/نامشخص بودن سود، unallocatedAmount برای مبلغ تخصیص‌نیافته پرداخت و rateStatus برای کیفیت نرخ مانده‌ها اضافه شده‌اند. فهرست دقیق ستون‌های هر گزارش را از options بگیرید. انتخاب fields قدیمی همچنان معتبر است؛ خروجی پیش‌فرض چند ستون جدید دارد.
