# Runbook

## Prerequisites

- PHP 8.2+ with `pdo_sqlsrv`, `sqlsrv`, `gd`, and `imap` extensions
- PHP `memory_limit` ≥ 128M (XlsxGenerator auto-raises to 512M during generation; if `ini_set` is disabled, set ≥ 512M in `php.ini`)
- PHP `post_max_size` ≥ 128M (required for send-registration-mail and send-refashion-mail APIs, both of which send a large `pdf_zip_base64`; default 64M is insufficient)
- SQL Server access (same instance, source + target databases)
- INSEE API key (apply at https://api.insee.fr)
- Tencent Cloud COS credentials (bucket in ap-guangzhou region)
- TTF signature fonts in `storage/fonts/` (7 files)

## Setup

```bash
composer install
cp .env.example .env
php artisan key:generate
```

Fill in `.env`:

```env
DB_SOURCE_HOST=<sqlserver_host>
DB_SOURCE_PORT=1433
DB_SOURCE_DATABASE=<source_db_name>
DB_SOURCE_USERNAME=<username>
DB_SOURCE_PASSWORD=<password>

DB_TARGET_HOST=<same_or_different_host>
DB_TARGET_PORT=1433
DB_TARGET_DATABASE=<target_db_name>
DB_TARGET_USERNAME=<username>
DB_TARGET_PASSWORD=<password>

OSS_ACCESS_KEY_ID=<cos_key>
OSS_SECRET_ACCESS_KEY=<cos_secret>
OSS_BUCKET=<bucket_name>

INSEE_API_KEY=<insee_key>

WECHAT_WEBHOOK_URL=<wechat_robot_webhook_url>

# SMTP (registration mail to Léko)
SMTP_MAIL_HOST=smtp.qiye.aliyun.com
SMTP_MAIL_PORT=465
SMTP_MAIL_USERNAME=info@seamew.de
SMTP_MAIL_PASSWORD=<password>
SMTP_MAIL_FROM_EMAIL=info@seamew.de
SMTP_MAIL_FROM_NAME="Sea&Mew Consulting GmbH"
SMTP_MAIL_TO=<leko_recipient_email>

# Refashion SMTP (Refashion registration mail; separate Aliyun account)
REFASHION_SMTP_HOST=smtp.qiye.aliyun.com
REFASHION_SMTP_PORT=465
REFASHION_SMTP_USERNAME=fr-epr@seamew.de
REFASHION_SMTP_PASSWORD=<password>
REFASHION_SMTP_FROM_EMAIL=fr-epr@seamew.de
REFASHION_SMTP_FROM_NAME="Sea&Mew Consulting GmbH"
REFASHION_REG_MAIL_TO=hotline@refashion.fr

# IMAP (inbox monitoring for Stripe invoice emails)
IMAP_HOST=imap.qiye.aliyun.com
IMAP_PORT=993
IMAP_ENCRYPTION=ssl

# Baidu OCR (invoice PDF text recognition)
BAIDU_OCR_API_KEY=<baidu_api_key>
BAIDU_OCR_SECRET_KEY=<baidu_secret_key>

# Volcano Ark Model (AI field extraction from OCR text)
VOLCANO_API_KEY=<volcano_api_key>
VOLCANO_MODEL=doubao-seed-1-6-251015

# French unified LEKO/CITEO file-generation API (all optional; defaults shown)
FR_FILE_API_AUTH_ENABLED=false
FR_FILE_API_AUTH_TOKEN=
FR_FILE_API_MAX_BODY_BYTES=1048576
FR_FILE_API_RATE_LIMIT_PER_MINUTE=60
```

## Migrations

Target DB migration runs on `sqlsrv_target` connection:

```bash
php artisan migrate --database=sqlsrv_target
```

## Processing

```bash
# Continuous daemon (runs until killed, 8s poll interval, SELECT TOP 1 per cycle)
php artisan epr:process

# Dry run (one-shot query only, no changes, then exits)
php artisan epr:process --dry-run
```

`epr:process` is an infinite loop. It exits automatically only when the source DB is unreachable for 10 consecutive cycles (exit code 1). Run it under a process supervisor (e.g. `supervisor`, `pm2`, or `systemd`) in production so it restarts automatically on exit. To stop manually: `Ctrl+C` or send SIGTERM.

```bash
# Mail invoice monitor (continuous IMAP polling, 8s interval)
php artisan epr:leko-monitor
```

`epr:leko-monitor` monitors the inbox for Stripe invoice emails. Same infinite loop pattern as `epr:process` — 10 consecutive DB failures → exit code 1. Run under supervisor in production.

```bash
# Combined mail monitor (recommended: single IMAP connection, both Léko + CITEO)
php artisan epr:mail-monitor

# CITEO invoice monitor (standalone: monitors 法国包装法CITEO账单 folder, 8s interval)
php artisan epr:citeo-monitor
```

`epr:mail-monitor` is the **recommended** daemon — connects to IMAP once per cycle and processes both Léko and CITEO folders sequentially. Single process avoids IMAP server connection conflicts.

`epr:citeo-monitor` (standalone) monitors the `法国包装法CITEO账单` folder for CITEO invoice emails. Same infinite loop pattern — 10 consecutive DB failures → exit code 1. Must run manually (NSSM causes IMAP issues under SYSTEM account, same as Léko mail monitor).

## Windows Deployment (NSSM)

- `FrEprProcess` (`epr:process`) - registered as an NSSM service via `register_services.bat`, auto-restarts on exit. The batch file removes old services first before reinstalling; it only registers `FrEprProcess`.
- **All mail monitors must run manually in a terminal** (not NSSM): `php artisan epr:mail-monitor` (recommended), or `epr:leko-monitor` / `epr:citeo-monitor` standalone. NSSM's SYSTEM account causes IMAP connections to hang after a few cycles; running under the logged-in user account works. Fresh IMAP client per cycle prevents state accumulation.

> **Dev testing**: `SourceRepository::fetchPendingRecords()` runs the PROD filter by default (`PushTaxBureauStatus=5 AND PushType=301 AND Country='FR' AND ServiceItemName='包装法注册' AND SupplierName='LEKO'` — see [CLAUDE.md](../CLAUDE.md)). A commented-out `BusinessSerialNumber = '...'` line inside the SQL can be uncommented for ad-hoc single-record testing.

## Operational Scripts (scripts/)

Standalone PHP scripts in `scripts/` (run manually, not part of the daemons):

- `export_citeo_csv.php` — export all `fr_epr_citeo` records to CSV: `php scripts/export_citeo_csv.php`. Output: `storage/export/citeo_export_YYYYMMDD_HHmmss.csv` (Chinese column headers, UTF-8 BOM for Excel; full record list, no filter).
- `copy_citeo_data.php` — one-off migration script: copies `fr_epr_citeo` rows from the `rpa_test` database to the `rpa` database (same SQL Server instance, same credentials; DB names hardcoded). Used once during the rpa_test → rpa cutover; not needed for routine operation.

## Troubleshooting

### "INSEE API error (HTTP 401)"
- Check `INSEE_API_KEY` in `.env`
- Register and get key at https://portail-api.insee.fr (create account → create application → subscribe to SIRENE API → copy key)

### "INSEE API error (HTTP 404)"
- SIREN number may be invalid or company not in SIRENE database
- Check `bc.RegNumber` in source DB has valid 9-digit SIREN

### "Source DB unreachable after 10 consecutive attempts, exiting process"
- The daemon detected 10 consecutive DB failures and exited with code 1
- Check SQL Server is running and reachable from this host
- Verify `pdo_sqlsrv` extension is loaded: `php -m | grep sqlsrv`
- Check host/port/credentials in `.env`
- Process supervisor should restart the daemon automatically; if not, check supervisor config

### "Could not connect to SQL Server"
- Verify `pdo_sqlsrv` extension is loaded: `php -m | grep sqlsrv`
- Check host/port/credentials in `.env`
- Ensure SQL Server allows remote connections

### "Mail monitor: IMAP connection failed"
- Verify `imap` extension is loaded: `php -m | findstr imap`
- Check `IMAP_HOST`, `IMAP_PORT`, `IMAP_ENCRYPTION` in `.env`
- Verify `SMTP_MAIL_USERNAME` / `SMTP_MAIL_PASSWORD` are correct (IMAP reuses SMTP credentials)
- Test: `php artisan tinker --execute="var_dump(imap_open('{imap.qiye.aliyun.com:993/ssl/novalidate-cert}INBOX', env('SMTP_MAIL_USERNAME'), env('SMTP_MAIL_PASSWORD')));"`

### "Mail monitor: could not parse company name"
- Email body format may have changed — check actual email content in `epr_daily` logs
- Parser expects `TO: Company Name` (EN) or `À : Company Name` (FR) at line start
- HTML emails are stripped to plain text before parsing

### "Mail monitor: company not found in source DB"
- Company name from email uses LIKE wildcard matching (e.g. `comp.NameEng LIKE 'Company%'`)
- Check the SQL query filter: `SupplierName='LEKO'`, `TypeName='包装法'`, `ServiceItemName='包装法注册'`, `Country='FR'`, `Status !=5`
- Verify the company exists in `SupplierInformation` table with `SupplierName='LEKO'`
- Company may not have an EPR registration record yet

### "Allowed memory size of 134217728 bytes exhausted" (PhpSpreadsheet)
- XlsxGenerator temporarily raises `memory_limit` to 512M during generation, then restores original limit
- If the fix doesn't work (e.g. on hosts where `ini_set` is disabled), increase `memory_limit` in `php.ini` or `.env` to at least 512M
- After writing, `disconnectWorksheets()` is called to release Spreadsheet memory

### "Unable to open template file"
- Templates must exist in `storage/Leko_Template.docx` and `storage/Leko_Template.xlsx`
- Check file permissions

### "OSS upload failed"
- Verify COS credentials and bucket name
- Check network access to COS endpoint
- Ensure bucket has write permissions for the configured key

### "WeChat notification failed" or "WeChat webhook URL not configured"
- Check `WECHAT_WEBHOOK_URL` is set in `.env`
- Verify the webhook URL is valid (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<key>`)
- Check network access to `qyapi.weixin.qq.com`
- Missing URL only logs a warning — notifications are non-blocking, processing continues

### Template tests skipped
- Tests auto-skip if template files not found in `storage/`
- This is expected in CI environments without templates

### "LibreOffice not found, install or set LIBREOFFICE_PATH env var"
- LibreOffice path is auto-detected by `SoftwarePathResolver` (searches common install dirs + PATH)
- If auto-detection fails, set `LIBREOFFICE_PATH` in `.env` with forward slashes: `LIBREOFFICE_PATH="C:/Program Files/LibreOffice/program/soffice.exe"`
- **Known pitfall**: `.env` with `LIBREOFFICE_PATH=` (empty value) causes detection bypass — `env()` returns empty string `''` not `null`, and `??` operator doesn't penetrate empty strings. AppServiceProvider now converts empty strings to `null` to let auto-detection work.
- Verify: `php artisan tinker --execute="echo App\Services\EprReg\SoftwarePathResolver::resolve('libreoffice');"` — should return the path or NULL
- Cached path stored in `storage/cache/software_paths.json`; stale cache auto-refreshes when path no longer exists

### "DOCX to PDF conversion failed" or PDF has 2 pages / missing content
- Template must have only a single body-level `<w:sectPr>` (no paragraph-level section breaks). Paragraph-level sectPr causes LibreOffice to produce multi-page PDFs with content loss.
- PdfConverter uses `firstPageOnly=true` to ensure single-page output
- If PDF content is still missing after template fix, check the DOCX XML for unexpected `<w:sectPr>` inside `<w:pPr>` elements
- Verify GD extension: `php -m | grep gd`
- Ensure `storage/fonts/` contains 7 TTF files (Animal Chariot.ttf, AidianSignatureTi-Regular-2.ttf, Aamonoline-2.ttf, PippaHandwriting-Regular-3.ttf, Ambarella-3.ttf, Signatura-Monoline-3.ttf, ZhiyongWrite-2.ttf)
- Signature generation fails if GD is missing or font files are absent

### Merge XLSX API returns 401 "Unauthorized: internal network only"
- The `internal.network` middleware only allows 172.16.x.x subnet requests
- If calling from outside the subnet, the request will be rejected with 401
- For development/testing, the IP restriction may be temporarily disabled — check `InternalNetworkOnly` middleware config; set `INTERNAL_NETWORK_ENFORCE=false` in `.env` for local dev
- **Fixed bug**: `ipInRange()` uses proper CIDR subnet matching (`172.16.0.0/16`); an earlier `str_starts_with` prefix check incorrectly accepted out-of-subnet IPs like `172.160.x.x`

### Merge XLSX API returns 400 "Attachment IDs not found"
- Verify all `F_Id` values exist in `Base_AnnexesFile` table in source DB
- Check IDs are UUID format (max 36 chars), not truncated

### Merge XLSX API returns 400 "All attachments must be xlsx type"
- `F_FileType` column in `Base_AnnexesFile` must be `xlsx` for all provided IDs
- PDF or other file type attachments cannot be merged

### Send Registration Mail API returns 401 "Unauthorized: internal network only"
- Same as merge-xlsx: `internal.network` middleware only allows 172.16.x.x subnet
- Also uses `parse.large.json` middleware for large JSON bodies with Base64 content

### Send Registration Mail API returns 400 "XLSX data rows(N) and PDF count(M) mismatch"
- The three-way consistency check requires: XLSX data rows = PDF file count = `epr_reg_info_ids` count
- Check that the XLSX URL points to a valid merged file with matching rows
- Check that the ZIP contains exactly the right number of PDF files
- Check that `epr_reg_info_ids` array length matches

### Send Registration Mail API returns 400 "PDF ZIP base64 decode failed"
- The `pdf_zip_base64` field must be valid Base64 encoding of a ZIP file
- Base64 strings with line breaks (common in email transport) are handled by `parse.large.json` middleware
- Verify the ZIP was correctly encoded before sending

### Send Registration Mail API returns 400 "EPRRegInfo ID cannot be empty"
- The `epr_reg_info_ids` array contains null or empty string elements
- Check the calling system sends valid UUID strings (not null/empty) in the array
- If `epr_reg_info_ids` is `[null]`, the caller likely passed a single null value instead of a valid UUID
- Check API logs at `storage/logs/api/reg-mail-YYYY-MM-DD.log` for full request details

### Send Registration Mail API returns 400 but request body seems correct
- PHP `post_max_size` must be ≥ 128M in `php.ini` (default 64M is too small for large `pdf_zip_base64` values)
- When `post_max_size` is exceeded, PHP silently discards POST data — `$request->all()` returns empty
- `ParseLargeJsonBody` middleware then re-parses from `php://input`, but the result may be incomplete
- Check `ParseLargeJsonBody` logs for "raw body is empty" or "json_decode failed" messages
- Also verify Apache `LimitRequestBody` ≥ 134217728 (128M) in httpd.conf

### Send Registration Mail API returns 400 "Mail send failed"
- Check `SMTP_MAIL_*` env vars are correctly configured
- Verify SMTP connection to Aliyun enterprise email (smtp.qiye.aliyun.com:465 SSL)
- Check the recipient email (`SMTP_MAIL_TO`) is valid
- Windows SSL certificate mismatch: code already bypasses SSL verification for Aliyun

### "Baidu OCR API error" or OCR returns empty text
- Check `BAIDU_OCR_API_KEY` and `BAIDU_OCR_SECRET_KEY` in `.env`
- Baidu OCR uses client_id+client_secret → access_token flow (not direct API key)
- PDF must be valid and not password-protected
- OCR is retried up to 3 times (3s interval) before giving up — check logs for attempt count
- After all retries exhausted, email-parsed fields are still saved (non-fatal)

### "Ark Model API error" or AI extraction returns empty fields
- Check `VOLCANO_API_KEY` and `VOLCANO_MODEL` in `.env`
- Default model: `doubao-seed-1-6-251015` (Volcano Doubao)
- AI extraction is retried up to 3 times (3s interval) together with OCR — check logs for attempt count
- After all retries exhausted, date_due and membership_id will be null in fr_epr_mail (non-fatal)

### "Mail monitor: PDF OCR/AI extraction failed after all retries"
- Both Léko and CITEO monitors retry OCR + AI extraction 3 times with 3-second intervals
- If all 3 attempts fail, the email is saved with partial data (email-parsed fields only) and marked as read
- Common causes: network instability, Baidu API rate limits, Volcano model timeout
- Check `storage/logs/mail/mail-invoice-YYYY-MM-DD.log` for per-attempt error details
- To re-process: mark the email as unread in the mail client and delete the corresponding `fr_epr_mail`/`fr_epr_citeo` record

### "[CITEO] No new CITEO invoice emails"
- CITEO emails are in the `法国包装法CITEO账单` folder (auto-archived by mailbox rules)
- Emails from `adv.emballages@espaceclients.citeo.com` with subject containing "facture" + "Citeo" are filtered
- If no matching emails exist, this is expected — log message confirms monitoring is active

### "[CITEO] All key fields null — parsing failed"
- Both email body parsing and PDF OCR/AI failed for a CITEO email
- Check OCR output in mail_invoice logs for the specific email
- WeChat notification is sent on complete parsing failure
- Partial data is still saved to fr_epr_citeo

### "[CITEO] Company name mismatch between email and PDF"
- Email-parsed company name differs from PDF OCR company name
- This is a warning, not an error — both names are stored in fr_epr_citeo for manual review
- The final company_name uses PDF name as primary, email name as fallback

### "[CITEO] AgencyBills dedup — updating existing record"
- Same email (BillUser + PaymentEndTime + EmailTime match) was previously processed
- Existing AgencyBills record is updated instead of inserting a duplicate
- This prevents duplicate entries when emails are accidentally marked unread and re-processed

## Smoke Test

```bash
# 1. Verify DB connections
php artisan tinker --execute="DB::connection('sqlsrv_source')->select('SELECT 1 AS test')"

# 2. Verify INSEE API
php artisan tinker --execute="app(App\Services\EprReg\InseeApiClient::class)->fetchCompanyData('443061841')"

# 3. Run dry run
php artisan epr:process --dry-run

# 4. Verify IMAP connection
php artisan tinker --execute="var_dump(imap_open('{imap.qiye.aliyun.com:993/ssl/novalidate-cert}INBOX', env('SMTP_MAIL_USERNAME'), env('SMTP_MAIL_PASSWORD')))"

# 5. Test merge-xlsx API (from within 172.16.x.x subnet)
curl -X POST http://localhost/fr_epr_reg/api/epr/merge-xlsx \
  -H 'Content-Type: application/json' \
  -d '{"AttachmentIDs": ["<uuid1>", "<uuid2>"]}'

# 6. Test send-registration-mail API (from within 172.16.x.x subnet)
# Requires: valid xlsx_url, pdf_zip_base64, and epr_reg_info_ids
curl -X POST http://localhost/fr_epr_reg/api/epr/send-registration-mail \
  -H 'Content-Type: application/json' \
  -d '{"xlsx_url": "<url>", "pdf_zip_base64": "<base64>", "epr_reg_info_ids": ["<id1>", "<id2>"]}'

# 7. Test generate-citeo-poa API (from within 172.16.x.x subnet; needs a valid EPRRegInfo Id)
curl -X POST http://localhost/fr_epr_reg/api/epr/generate-citeo-poa \
  -H 'Content-Type: application/json' \
  -d '{"Id": "<epr_reg_info_uuid>"}'

# 8. Test generate-refashion-poa API (ECO TLC record; from within 172.16.x.x subnet)
curl -X POST http://localhost/fr_epr_reg/api/epr/generate-refashion-poa \
  -H 'Content-Type: application/json' \
  -d '{"Id": "<epr_reg_info_uuid>"}'

# 9. Test send-refashion-mail API (from within 172.16.x.x subnet)
# Requires: valid pdf_zip_base64 and ECO TLC epr_reg_info_ids
curl -X POST http://localhost/fr_epr_reg/api/epr/send-refashion-mail \
  -H 'Content-Type: application/json' \
  -d '{"pdf_zip_base64": "<base64>", "epr_reg_info_ids": ["<id1>", "<id2>"]}'

# 10. Test generate-refashion-uin-certificate API (from within 172.16.x.x subnet)
curl -X POST http://localhost/fr_epr_reg/api/epr/generate-refashion-uin-certificate \
  -H 'Content-Type: application/json' \
  -d '{"NameEng": "Acme France SARL", "CountryReg": "France", "BusinessLicenseNo": "FR123456789", "UIN": "FR-ABC-123", "CompanyCnName": "测试公司"}'
```

# 11. Test French unified LEKO/CITEO file-generation API
# (SaaS relay host; optional Bearer only if FR_FILE_API_AUTH_ENABLED=true.
#  Sample payloads are unified with the new-system docs:
#  E:\ou\meiou-app\app_withdrawn\docs\法国LEKO注册文件.json / 法国CITEO注册文件.json,
#  identical to UNIFIED_API_DESIGN.md §4.4.21/§4.4.7 examples.
#  Endpoint spec: docs/french-file-generation-api.md)
curl -X POST http://localhost/fr_epr_reg/api/epr/fr/file-generation \
  -H 'Content-Type: application/json' \
  -d @"E:/ou/meiou-app/app_withdrawn/docs/法国CITEO注册文件.json"

curl -X POST http://localhost/fr_epr_reg/api/epr/fr/file-generation \
  -H 'Content-Type: application/json' \
  -d @"E:/ou/meiou-app/app_withdrawn/docs/法国LEKO注册文件.json"

# 12. Test French LEKO merge API (new system)
#  Data.files = 2+ OSS **relative paths** returned by step 11 (Data.files[].url).
#  Endpoint spec: docs/french-merge-xlsx-api.md; contract UNIFIED_API_DESIGN §4.4.22)
curl -X POST http://localhost/fr_epr_reg/api/epr/fr/merge-xlsx \
  -H 'Content-Type: application/json' \
  -d '{"PushType":"FR_EPR_REGISTER_LEKO_FILE_MERGE","Country":"FR","bizParam":{},"Data":{"files":[{"url":"common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/Leko_Template list of companies_v3.11 2026.xlsx","type":"EPR业务申请表"},{"url":"common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000002/Leko_Template list of companies_v3.11 2026.xlsx","type":"EPR业务申请表"}]}}'

# 13. Test French LEKO registration-mail API (new system)
#  Merged XLSX key from step 12 + one POA PDF key per company from step 11.
#  Endpoint spec: docs/french-registration-mail-api.md; contract UNIFIED_API_DESIGN §4.4.23)
curl -X POST http://localhost/fr_epr_reg/api/epr/fr/registration-mail \
  -H 'Content-Type: application/json' \
  -d '{"PushType":"FR_EPR_REGISTER_LEKO","Country":"FR","bizParam":{},"Data":{"files":[{"url":"common-test/generatefile/2026/fr_epr_reg/merged/merged_20260912_101500_a1b2c3d4.xlsx","type":"EPR业务申请表"},{"url":"common-test/generatefile/2026/fr_epr_reg/leko/FREPR2026000001/POA-EXAMPLE COMPANY SAS.pdf","type":"授权书"}]}}'

## Léko RPA Scripts (`python/`) — 下号 / 证书

- 依赖：`pip install pymssql cos-python-sdk-v5 requests`。
- `step1_get` 取任务条件：`PushTaxBureauStatus=3` + `PushType='301'` + `包装法`/`包装法注册` + `SupplierName='LEKO'` + `EPRBusinessRecord.Status=3`，取到后立即把 `ModificationDate` 置为当前时间（租约）；`step3_save` 成功后回写 `RegBackNumber` 且 `PushTaxBureauStatus=4`（下号完成）。
- 连接参数（host/user/password/database/port）由调用方注入；脚本默认值仅为占位。
- 与 API 流程的关系：`/api/epr/fr/*` 不回写状态，若 RPA 需处理 API 流程单据，由新系统侧置状态 3 或调整取数条件。详见 [architecture](architecture.md)。

## Monitoring

- EPR-specific logs: `storage/logs/epr/epr-YYYY-MM-DD.log` (daily rotation, 30-day retention)
- Mail invoice monitor logs: `storage/logs/mail/mail-invoice-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_MAIL_DAYS` env var). CITEO monitor also uses this channel with `[CITEO]` prefix in messages.
- CITEO invoice logs: `storage/logs/citeo/citeo-invoice-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_CITEO_DAYS` env var)
- Merge XLSX API logs: `storage/logs/api/merge-xlsx-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- Registration mail API logs: `storage/logs/api/reg-mail-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- CITEO POA API logs: `storage/logs/api/citeo-poa-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- Refashion POA API logs: `storage/logs/api/refashion-poa-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- Refashion registration mail API logs: `storage/logs/api/refashion-mail-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- Refashion UIN certificate API logs: `storage/logs/api/refashion-uin-YYYY-MM-DD.log` (daily rotation, 30-day retention, `LOG_API_DAYS` env var)
- API request raw base64 dumps: `storage/logs/api/raw/{field}_{timestamp}_{hash}.txt` (auto-saved when base64 field >10KB)
- General Laravel logs: `storage/logs/laravel-YYYY-MM-DD.log`
- Each failed record logs: `INSEE API Failed for record {Id}: {error}` or `Processing failed for record {Id}: {error}`
- Source DB `EPRRegInfo.PushTaxBureauStatus`: 5=pending, 6=files generated (awaiting registration mail), 3=推送成功, 7=failure; `Remarks` column stores failure reason for status=7
- Target DB `fr_epr_reg.status`: 0=pending, 1=processing, 2=success, 3=failed
- `error_message` column stores failure reason for status=3 records
- `pdf_attachment_id` / `xlsx_attachment_id` store the `F_Id` of the corresponding rows in `Base_AnnexesFile` (source DB); NULL means attachments have not been written yet for that record