# GuaranteeApp — بک‌اند (PHP API روی هاست اشتراکی)

> پیش‌نیاز: `00-Overview` و `01-Database`. این فایل تمام Endpointها را یک‌جا تعریف می‌کند.
> بدون فریم‌ورک؛ PHP ساده + PDO. مقصد: cPanel (PHP ≥ 8.0، MySQL/MariaDB).

---

## ۱. ساختار پروژه (همان چیزی که روی هاست آپلود می‌شود)

```
config.php               ← تنظیمات (DB، توکن تلگرام، TEST_KEY) — قبل از آپلود پر شود
.htaccess                ← بستن دسترسی وب به config.php, *.sql, *.log
panel.php                ← پنل عملیات (05-WebPanel)
warranty.php             ← صفحهٔ عمومی گارانتی
test.php                 ← سلامت‌سنجی + تست زندهٔ زنجیره + نمایش لاگ (با TEST_KEY)
database.sql             ← اسکیمای 01-Database
api/
  scan.php
  product.php            ← GET ?serial=  (محافظت‌شده)
  warranty/activate.php
  sync/{options,models,products,templates}.php
  restore/{options,models,batches,products,templates}.php
lib/                     ← (.htaccess: Deny from all)
  db.php                 ← PDO تک‌نمونه‌ای؛ خطای «تنظیمات ناقص» روشن
  Response.php           ← قالب JSON
  Auth.php               ← بررسی X-Api-Key
  RateLimiter.php        ← فایل‌محور
  Logger.php             ← gw_log
  TelegramNotifier.php   ← sendToAllAdmins + اعلان‌های Orphan/گارانتی
  PartNumberValidator.php
  ProductService.php     ← تاریخچهٔ سریال (مشترک API و ربات)
  InventoryService.php   ← موجودی و گزارش روزانه (مشترک ربات)
telegram/                ← 06-TelegramBot
assets/{css/app.css, js/{panel,warranty,test}.js}
logs/                    ← (.htaccess: Deny from all)  app.log خودکار ساخته می‌شود
```

### `config.php`
```php
define('DB_HOST','localhost');  define('DB_NAME','');  define('DB_USER','');  define('DB_PASS','');
define('APP_TIMEZONE','Asia/Tehran');
define('TEST_KEY','');                 // خالی = test.php و ابزارهای setup غیرفعال
define('TELEGRAM_BOT_TOKEN','');       // خالی = اعلان‌ها بی‌صدا رد می‌شوند و در لاگ ثبت می‌شوند
define('RATE_LIMIT_PER_MIN', 30);
define('GW_INVENTORY_MODE','A');       // A: بسته‌بندی−خروج | B: تولید−خروج
```
اگر `DB_*` خالی باشد، `lib/db.php` پیام روشن «تنظیمات ناقص» می‌دهد (نه صفحهٔ سفید).

---

## ۲. قرارداد پاسخ

```json
{ "success": true,  "data": { } }
{ "success": false, "error": { "code": "SERIAL_NOT_FOUND", "message": "متن قابل‌نمایش" } }
```
- حالت‌های منطقی (پیدا نشد، تکراری، تعارض) با **HTTP 200** و `success:false`.
- فقط خطای واقعی: `401` (کلید)، `429` (Rate limit)، `500`.
- همهٔ POSTها JSON (`Content-Type: application/json`)، UTF-8.

| کد | معنی | Endpoint |
|---|---|---|
| `AUTH_INVALID_KEY` | کلید API نامعتبر/غیرفعال | sync/*, restore/*, product |
| `VALIDATION_ERROR` | فیلد الزامی غایب/فرمت یا بازهٔ غلط | همه |
| `MODEL_NOT_FOUND` | مدل متناظر هنوز سینک نشده | sync/products |
| `MODEL_LOCKED_CONFLICT` | تغییر ترکیب آپشن مدل/آپشن قفل‌شده | sync/options, sync/models |
| `SERIAL_ALREADY_EXISTS` | سریال موجود، متعلق به دستهٔ دیگر | sync/products (ردیفی) |
| `SERIAL_NOT_FOUND` | سریال در `products` نیست | scan, warranty/activate |
| `SERIAL_ALREADY_REGISTERED` | رویداد تکراری برای همان نوع | scan |
| `SERIAL_ALREADY_ACTIVATED` | گارانتی قبلاً فعال شده (همراه `activatedAt`) | warranty/activate |
| `RATE_LIMITED` | بیش از حد مجاز (HTTP 429) | scan, warranty/activate |
| `INTERNAL_ERROR` | خطای غیرمنتظره | همه |

---

## ۳. احراز هویت و امنیت

| گروه | مصرف‌کننده | روش |
|---|---|---|
| `sync/*`, `restore/*`, `product` | اپ دسکتاپ / ابزار تست | هدر `X-Api-Key`؛ سرور SHA-256 می‌گیرد و در `api_keys.key_hash` (active=1) جستجو می‌کند |
| `scan`, `warranty/activate` | پنل وب / مشتری | بدون لاگین؛ Rate limit بر IP |
| `telegram/webhook.php` | تلگرام | هدر `X-Telegram-Bot-Api-Secret-Token` (بخش `06`) |
| `telegram/daily_report.php`, `setup_webhook.php`, `test.php` | Cron / مدیر | CLI یا `?key=TEST_KEY` |

- **Rate limit:** فایل‌محور، `RATE_LIMIT_PER_MIN` درخواست در ۶۰ ثانیه به‌ازای IP روی scan / activate.
- ورودی‌ها همه با PDO prepared statement؛ خروجی HTML (وب) escape شود.
- `config.php`، `logs/`، `lib/` و `*.sql` با `.htaccess` بسته‌اند.

---

## ۴. گروه وب

### ۴.۱ `POST api/scan.php`
```json
// Request
{ "serial": "IP0756z-1223-260923-0001", "eventType": "packaging" }   // یا "warehouse_exit"
// موفق
{ "success": true, "data": { "status": "registered", "serial": "...", "productModelName": "...", "packagingMissing": false } }
// تکراری
{ "success": false, "error": { "code": "SERIAL_ALREADY_REGISTERED", "message": "قبلاً ثبت شده بود" } }
// ناشناخته
{ "success": false, "error": { "code": "SERIAL_NOT_FOUND", "message": "این محصول در دیتابیس نیست" } }
```
منطق:
1. `serial` را trim کن؛ اگر ورودی URL بود، پارامتر `serial` را استخراج کن (متن خام سریال هم پذیرفته می‌شود).
2. جستجو در `products`.
3. **پیدا نشد:** درج `warehouse_events(product_id=NULL, is_orphan=1)`؛ اگر درج جدید بود (نه برخورد با `dup_guard`) ← اعلان Orphan تلگرام (فقط بار اول)؛ پاسخ `SERIAL_NOT_FOUND`.
4. **پیدا شد:** درج رویداد؛ برخورد `dup_guard` ← `SERIAL_ALREADY_REGISTERED`.
5. `warehouse_exit` بدون `packaging` قبلی: **مجاز** + `packagingMissing:true` در پاسخ (هشدار غیرمسدودکننده).
6. تکراری‌بودن به‌ازای هر نوع عملیات است؛ یک سریال می‌تواند هم `packaging` و هم `warehouse_exit` داشته باشد.

### ۴.۲ `POST api/warranty/activate.php`
```json
// Request
{ "serial": "..." }
// اولین بار
{ "success": true, "data": { "status": "activated", "activatedAt": "2026-09-25T18:45:00" } }
// تکراری
{ "success": false, "error": { "code": "SERIAL_ALREADY_ACTIVATED", "message": "...", "activatedAt": "2026-09-25T18:45:00" } }
```
منطق:
1. سریال در `products` نبود ← `SERIAL_NOT_FOUND` (صفحه پیام خنثی نشان می‌دهد) + لاگ.
2. `warranty_activations` (یکتا بر سریال): تکراری ← `SERIAL_ALREADY_ACTIVATED`.
3. اولین بار: درج؛ اگر `SELECT COUNT(*) FROM warehouse_events WHERE product_id=:id` صفر بود ← اعلان «گارانتی بدون سابقهٔ انبار» + `admin_notified=1`.

### ۴.۳ `GET api/product.php?serial=...` (محافظت با `X-Api-Key`)
```json
{ "success": true, "data": { "serial":"...", "modelName":"...", "productionDate":"2026-09-23",
  "packagingAt":"2026-09-24T14:10:00", "warehouseExitAt":null, "warrantyActivatedAt":null } }
```
منطق در `ProductService` است؛ ربات همان را مستقیم (بدون HTTP) صدا می‌زند.

---

## ۵. گروه Sync (اپ دسکتاپ ← سرور)

ترتیب ارسال: **options → models → products → templates**. همه Idempotent بر پایهٔ `local_uuid`.

### ۵.۱ `POST api/sync/options.php` (یک خانواده در هر درخواست)
```json
{ "familyLocalUuid":"u-f1", "familyNumber":1, "familyName":"لنز",
  "options":[ {"localUuid":"u-1","valueIndex":1,"name":"2.8mm","isLocked":false},
              {"localUuid":"u-2","valueIndex":2,"name":"4mm","isLocked":true} ] }
// Response
{ "success":true, "data":{ "syncedOptionUuids":["u-1","u-2"] } }
```
Upsert. اگر `value_index` برای همان خانواده با `local_uuid` دیگری ثبت شده و آن آپشن `is_locked=1` است ← `MODEL_LOCKED_CONFLICT`.

### ۵.۲ `POST api/sync/models.php`
```json
{ "localUuid":"u-m1", "name":"IP Camera 4mm WiFi", "baseCode":"IP0756z",
  "partNumberCode":"IP0756z-1223", "optionLocalUuids":["u-2","u-c3"], "isLocked":true,
  "warrantyMonths":12, "warrantyLabelText":"1 Year Warranty" }
// Response
{ "success":true, "data":{ "modelId":42, "partNumberCode":"IP0756z-1223" } }
```
- `warrantyMonths` ۱..۱۲۰ و `warrantyLabelText` الزامی (`VALIDATION_ERROR`).
- `PartNumberValidator`: part number باید با `baseCode` + کدهای آپشن‌های ارسالی سازگار باشد.
- اگر مدل قبلاً `is_locked=1` سینک شده و **ترکیب آپشن‌ها** فرق دارد ← `MODEL_LOCKED_CONFLICT`. تغییر نام/گارانتی روی مدل قفل‌شده **مجاز** و تعارض نیست.

### ۵.۳ `POST api/sync/products.php` (سقف ۲۰۰ سریال در هر درخواست)
```json
{ "batch": { "localUuid":"u-b1", "productModelLocalUuid":"u-m1", "batchCode":"260923-B1", "quantity":50 },
  "products": [
    { "serialNumber":"IP0756z-1223-260923-0001", "productionDate":"2026-09-23",
      "label1PrintedAt":"2026-09-23T10:05:00", "label2PrintedAt":null } ] }
// Response
{ "success":true, "data":{ "insertedCount":48, "updatedCount":1, "matchedOrphanEvents":2,
  "rejected":[ {"serialNumber":"...","code":"SERIAL_ALREADY_EXISTS"} ] } }
```
منطق:
1. مدل با `productModelLocalUuid` نبود ← `MODEL_NOT_FOUND`.
2. Upsert دسته بر `local_uuid` (برای دستهٔ بیش از ۲۰۰ سریال، چند درخواست با همان `batch`).
3. هر سریال باید با `part_number_code` مدل + `-` شروع شود (وگرنه ردیف رد می‌شود با `VALIDATION_ERROR`).
4. سریال جدید ← INSERT + ادغام Orphan (بخش ۸.۲).
5. سریال موجود با **همان دستهٔ `local_uuid`** ← **به‌روزرسانی** `label1/2_printed_at` (فقط مقدار غیر-null؛ Retry/سینک مجدد تعارض نیست). با دستهٔ متفاوت ← رد ردیف با `SERIAL_ALREADY_EXISTS`.
6. خطای یک ردیف بقیه را متوقف نمی‌کند؛ لیست `rejected` برگردانده و **اپ آن را در UI نشان می‌دهد**.

### ۵.۴ `POST api/sync/templates.php` (پشتیبان قالب)
```json
{ "labelType":1, "profileName":"BrandA", "fileName":"L1_BrandA.btw",
  "contentHash":"<sha256 hex>", "printWarranty":true, "contentBase64":"..." }
// Response
{ "success":true, "data":{ "id":3, "updated":true } }
```
- Upsert بر `UNIQUE(label_type, profile_name)`. سقف فایل ۴ مگابایت (`VALIDATION_ERROR`). اگر `content_hash` برابر موجود است، بدون نوشتن `updated:false`.
- هاست باید `post_max_size ≥ 8M` و `max_allowed_packet ≥ 8M` داشته باشد (Base64 حدود ۳۳٪ بزرگ‌تر است).

---

## ۶. گروه Restore (سرور ← سیستم جدید) — فقط‌خواندنی، با `X-Api-Key`

| Endpoint | خروجی (`data`) |
|---|---|
| `GET api/restore/options.php` | `families:[{localUuid, familyNumber, name, options:[{localUuid, valueIndex, name, isLocked}]}]` |
| `GET api/restore/models.php` | `models:[{localUuid, name, baseCode, partNumberCode, isLocked, warrantyMonths, warrantyLabelText, optionLocalUuids}]` |
| `GET api/restore/batches.php?afterId=&limit=500` | `items:[{id, localUuid, productModelLocalUuid, batchCode, quantity, createdAt}]` + `nextAfterId` |
| `GET api/restore/products.php?afterId=&limit=500` | `items:[{id, serialNumber, batchLocalUuid, productionDate, label1PrintedAt, label2PrintedAt}]` + `nextAfterId` |
| `GET api/restore/templates.php` | `items:[{id, labelType, profileName, fileName, contentHash, printWarranty, updatedAt}]` |
| `GET api/restore/templates.php?id=N` | همان فیلدها + `contentBase64` |

- صفحه‌بندی بر پایهٔ `id > afterId ORDER BY id LIMIT n`؛ `nextAfterId = null` یعنی پایان. سقف `limit` = ۵۰۰.
- قابل ادامه بعد از قطع اینترنت (کلاینت آخرین `afterId` را نگه می‌دارد).

---

## ۷. سرویس‌های داخلی (بدون Endpoint عمومی)

برای ربات، فراخوانی مستقیم توابع PHP (بدون round-trip HTTP):

- **`ProductService::history($serial)`** ← خروجی بخش ۴.۳ + نام مدل + وضعیت گارانتی.
- **`InventoryService::stock()`**: به‌ازای مدل؛ حالت A: `COUNT(packaging) − COUNT(warehouse_exit)`؛ حالت B: `COUNT(products) − COUNT(warehouse_exit)`. فقط مقادیر مثبت نمایش داده می‌شوند.
- **`InventoryService::daily($date)`**: تولید به تفکیک مدل (`products.production_date = :d` گروه‌بندی بر مدل)، تعداد `packaging` و `warehouse_exit` با `DATE(scanned_at) = :d`.

تاریخ را همیشه PHP (منطقهٔ تهران) به‌عنوان پارامتر می‌دهد.

---

## ۸. جزئیات مهم

### ۸.۱ اعلان‌های تلگرام
`TelegramNotifier` پیام‌ها را مستقیم از PHP در لحظهٔ رویداد می‌فرستد (قالب‌ها در `06-TelegramBot §۶`). اگر توکن یا ادمین فعال نباشد، پیام بی‌صدا رد و دلیلش لاگ می‌شود؛ هرگز مانع پاسخ API نمی‌شود.

### ۸.۲ ادغام Orphan (بعد از هر INSERT موفق در `products`)
```sql
UPDATE warehouse_events
SET product_id = :productId, is_orphan = 0, matched_at = :now
WHERE serial_number = :serial AND is_orphan = 1
```
تعداد ردیف‌های به‌روزشده به `matchedOrphanEvents` اضافه می‌شود.

### ۸.۳ لاگ
`gw_log(level, message, context[])` ← `logs/app.log`:
```
[2026-09-27 11:20:45][INFO] scan registered | {"serial":"...","event":"packaging"}
```
ثبت: اسکن (موفق/تکراری/Orphan)، فعال‌سازی، ارسال تلگرام، خطای DB، رد شدن secret وب‌هوک، خطاهای sync.

### ۸.۴ `test.php?key=TEST_KEY`
۱) وضعیت سرور (config، اتصال DB، وجود جدول‌ها، `post_max_size`، `max_allowed_packet`، خروجی HTTPS) ۲) تست زندهٔ زنجیره با دکمه‌های شمارهٔ ۱ تا ۹: ساخت محصول `TEST-WEB-0001` ← scan بسته‌بندی ← تکراری ← Orphan ← خروج انبار ← فعال‌سازی ← تکراری ← استعلام ← پیام تست تلگرام ۳) نمایش ۸۰ خط آخر لاگ ۴) دکمهٔ پاک‌سازی دادهٔ تست (`TEST-%`). بعد از استقرار حذف یا `TEST_KEY` خالی شود.

---

## ۹. چک‌لیست تست یکپارچگی

- [ ] سریال سینک‌نشده را در پنل اسکن کن ← «در دیتابیس نیست» + پیام Orphan در تلگرام.
- [ ] همان سریال را سینک کن ← `is_orphan=0` و `product_id` پر؛ `matchedOrphanEvents ≥ 1`.
- [ ] سریال سینک‌شدهٔ بدون سابقهٔ انبار را در `warranty.php` فعال کن ← هشدار ادمین؛ مشتری فقط «فعال شد».
- [ ] فعال‌سازی دوباره ← «قبلاً فعال شده» با تاریخ.
- [ ] سینک مجدد همان دسته ← خطای تعارض نمی‌دهد (`updatedCount`).
- [ ] مدل قفل‌شده با ترکیب آپشن متفاوت ← `MODEL_LOCKED_CONFLICT`؛ تغییر گارانتی ← OK.
- [ ] `/inventory` و `/report` با دادهٔ تست همخوان.
- [ ] restore با `afterId` تا `nextAfterId=null`.
