# SPBU Manager — Backend (FastAPI)

## File Structure

```
app/
├── main.py
├── core/
│   ├── config.py
│   ├── security.py             ← JWT, password hash
│   └── database.py             ← async session factory
├── models/
│   ├── base.py                 ← Base, TimestampMixin, SoftDeleteMixin
│   ├── spbu.py                 ← Spbu, Shift, Island, Nozzle, Tangki, KalibrasiTangki, Tenant, KontrakSewa
│   ├── operational.py          ← LaporanShift, PenjualanNozzle, StockAdjustment, StockAdjustmentItem, StockAdjustmentItemFoto
│   ├── penebusan.py            ← Penebusan, PenebusanItem, StatusPenebusan
│   ├── penerimaan.py           ← Penerimaan, PenerimaanItem, PenerimaanFoto
│   ├── expenses.py             ← ExpenseKategori, Expense
│   ├── penyetoran.py           ← Penyetoran, StatusPenyetoran
│   ├── end_to_end.py           ← EndToEndCycle, StatusEndToEnd
│   ├── product.py
│   ├── user.py
│   ├── role.py
│   └── system.py
├── schemas/                    ← {Model}Create, {Model}Update, {Model}Response (Pydantic v2)
├── repositories/               ← satu file per domain, query SQLAlchemy async
├── services/                   ← business logic, panggil repository
├── routers/                    ← HTTP layer saja, panggil service
└── utils/
    ├── kalibrasi.py            ← interpolasi dipstick mm → volume liter
    ├── file_upload.py          ← save_upload(), delete_file(), get_spbu_code()
    ├── pdf_parser.py           ← parse PDF DO Pertamina
    ├── audit.py                ← log_action() — call dari services untuk audit trail
    └── anomali.py              ← ❌ TODO
```

## Arsitektur

**Pattern wajib:** router → service → repository → model. Tidak ada raw query di router/service.

**Semua endpoint auth:** wajib `Depends(get_current_user)`.

**Naming:**
- Model: `PascalCase` singular — `LaporanShift`, `StockAdjustment`
- Table: `snake_case` dengan prefix `master_` untuk master data
- Schema: `{Model}Create`, `{Model}Update`, `{Model}Response`
- Service: verb + noun — `create_laporan`, `submit_adjustment`, `recall_laporan`

**Tipe data penting:** Harga, volume, rupiah → `Numeric(precision=15, scale=3)`. **Jangan float.**

**Soft delete / Timezone / Status flow / Approved=Locked / Permission rules:** → root `CLAUDE.md` §2, §5.2, §9

## API Conventions

**Prefix:** `/api/v1/`

**Response format:**
```json
{ "data": { ... }, "meta": { "total": 100, "page": 1, "per_page": 20 }, "message": "..." }
```

**Error format:**
```json
{ "error": { "code": "INSUFFICIENT_STOCK", "message": "...", "details": { ... } } }
```

**Endpoint utama:**
```
-- Penjualan
GET/POST  /api/v1/spbus/{spbu_id}/laporan-shift
GET       /api/v1/spbus/{spbu_id}/laporan-shift/teller-init         ← pre-fill teller + can_approve flag
POST      /api/v1/spbus/{spbu_id}/laporan-shift/extract-from-image  ← multipart: file + old_foto_url?
PATCH     /api/v1/spbus/{spbu_id}/laporan-shift/{id}/submit
PATCH     /api/v1/spbus/{spbu_id}/laporan-shift/{id}/recall         ← SUBMITTED → DRAFT (operator)
PATCH     /api/v1/spbus/{spbu_id}/laporan-shift/{id}/review         ← approve/reject (admin)
PATCH     /api/v1/spbus/{spbu_id}/laporan-shift/{id}/unlock         ← APPROVED → DRAFT (admin)
GET       /api/v1/spbus/{spbu_id}/laporan-shift/{id}/activity-log   ← full audit trail dari audit_log table

-- Stock Adjustment
GET/POST  /api/v1/spbus/{spbu_id}/stock-adjustment
GET       /api/v1/spbus/{spbu_id}/stock-adjustment/stock-init
GET       /api/v1/spbus/{spbu_id}/stock-adjustment/calculate-volume  ← dipstick mm → L on-the-fly
POST      /api/v1/spbus/{spbu_id}/stock-adjustment/{id}/items/{item_id}/fotos

-- Penebusan / Penerimaan / Expenses / Penyetoran
GET/POST  /api/v1/spbus/{spbu_id}/{modul}
GET       /api/v1/spbus/{spbu_id}/penyetoran/summary/{tanggal}  ← suggested setor
GET       /api/v1/spbus/{spbu_id}/penerimaan/calculate-volume   ← dipstick mm → L (reuses kalibrasi)
POST      /api/v1/spbus/{spbu_id}/penerimaan/{id}/fotos         ← tipe: truck|stick_t3|compartment_buka|compartment_kosong|surat_jalan|stick_awal|stick_akhir|dipstick_sebelum|dipstick_sesudah|atg_sebelum|atg_sesudah

-- Approval flow (penerimaan + expenses)
POST      /api/v1/spbus/{spbu_id}/penerimaan/{id}/submit
POST      /api/v1/spbus/{spbu_id}/penerimaan/{id}/review        ← action: approve|reject + catatan
POST      /api/v1/spbus/{spbu_id}/penerimaan/{id}/unlock        ← alasan required
GET       /api/v1/spbus/{spbu_id}/penerimaan/{id}/bast-pdf
POST      /api/v1/spbus/{spbu_id}/expenses/{id}/submit
POST      /api/v1/spbus/{spbu_id}/expenses/{id}/recall
POST      /api/v1/spbus/{spbu_id}/expenses/{id}/review          ← action: approve|reject + catatan
POST      /api/v1/spbus/{spbu_id}/expenses/{id}/unlock          ← alasan required
GET       /api/v1/spbus/{spbu_id}/expenses/{id}/bast-pdf

-- Penyetoran (per-shift batch model)
GET/POST  /api/v1/spbus/{spbu_id}/penyetoran/batches
GET       /api/v1/spbus/{spbu_id}/penyetoran/batches/{id}
POST      /api/v1/spbus/{spbu_id}/penyetoran/batches/{id}/submit
POST      /api/v1/spbus/{spbu_id}/penyetoran/batches/{id}/review ← action: approve only
GET       /api/v1/spbus/{spbu_id}/penyetoran/batches/{id}/bast-pdf

-- BAST PDF (semua modul)
GET       /api/v1/spbus/{spbu_id}/laporan-shift/{id}/bast-pdf
GET       /api/v1/spbus/{spbu_id}/stock-adjustment/{id}/bast-pdf

-- End-to-End Reconciliation
GET/POST  /api/v1/spbus/{spbu_id}/end-to-end
PATCH     /api/v1/spbus/{spbu_id}/end-to-end/{id}/close

-- Roles
GET/POST  /api/v1/roles
GET/PUT   /api/v1/roles/{id}/permissions

-- System
GET/PATCH /api/v1/system/config/{maintenance|env-mode}

-- AI Assistant
POST      /api/v1/assistant   ← app/assistant/ (providers.py, service.py, tools.py)
```

## File Upload

Selalu gunakan `UploadContext` + `save_upload()` dari `app/utils/file_upload.py`. Max 10 MB. → root `CLAUDE.md` §2 untuk path convention.

**Utilities di `app/utils/file_upload.py`:**
- `save_upload(file_bytes, filename, ctx)` → simpan file, return stored URL (`/uploads/...` atau GDrive URL)
- `delete_file(stored_url)` → hapus file dari storage (local atau GDrive). Fire-and-forget, errors swallowed.
- `get_spbu_code(db, spbu_id)` → fetch `nomor_pertamina` SPBU untuk folder naming. Fallback ke `str(spbu_id)`.

**Rules:**
- `UploadContext.spbu_code` **harus** diisi dengan `nomor_pertamina` (bukan `spbu_id`). Selalu gunakan `get_spbu_code(db, spbu_id)`.
- Re-upload (replace): hapus file lama via `delete_file(old_url)` SETELAH file baru berhasil disimpan.
- Delete record: hapus file terkait via `delete_file(url)` SETELAH DB commit sukses.
- File disajikan via `GET /api/v1/files/{path}` (authenticated, `app/routers/files.py`). Frontend: gunakan `getFileUrl()` dari `lib/utils/file-url.ts`.

**Service pattern setelah commit:**
Setelah `await db.commit()`, jangan `await db.refresh(laporan)` lalu akses relationship — ini trigger `MissingGreenlet` error di async context. Reload dengan explicit query:
```python
await db.commit()
# ✅ Reload dengan eager loading
laporan = await operational_repository.get_laporan_by_id(db, laporan.id, spbu_id)
return _build_detail_response(laporan)
# ❌ Jangan ini
await db.refresh(laporan)  # relationship jadi expired → greenlet error saat akses
```

## Alembic Migration History

| Revision | Deskripsi |
|----------|-----------|
| `75804624475a` | Initial schema |
| `e57834c99202` | Add island, nozzle, tangki, tenant, kontrak |
| `f3a1b2c4d5e6` | Rename tables to master_ prefix |
| `a1b2c3d4e5f6` | Add system_config table |
| `b2c3d4e5f6a1` | Add deleted_at (soft delete) + environment_mode seed |
| `c3d4e5f6a1b2` | Add laporan_shift + penjualan_nozzle tables |
| `d4e5f6a1b2c3` | Dual teller (manual/digital) + threshold pct fields |
| `e5f6a1b2c3d4` | Add recall audit fields (recalled_by_id, recalled_at) |
| `f1a2b3c4d5e6` | Add stock_adjustment + stock_adjustment_item tables |
| `g2b3c4d5e6f7` | Add penebusan + penebusan_item tables |
| `h3c4d5e6f7a8` | Add is_manual to penebusan + create penerimaan table |
| `i4d5e6f7a8b9` | Rebuild penerimaan — header/item/foto structure |
| `j5e6f7a8b9c0` | Add stock_adjustment_item_foto table |
| `k6f7a8b9c0d1` | Add expense_kategori + expenses tables |
| `m9c0d1e2f3a4` | Move losses thresholds from master_spbu to master_produk |
| `n0d1e2f3a4b5` | Add penyetoran table |
| `o1e2f3a4b5c6` | Add end_to_end_cycle table (E2E reconciliation) |
| `a3e4f5g6h7i8` | Add atg_sebelum_mm, atg_sesudah_mm to penerimaan_item |
| `b4e5f6g7h8i9` | Merge: atg penerimaan into main head |
| `c5f6g7h8i9j0` | Add unlocked_by_id + unlocked_at to laporan_shift and stock_adjustment |
| `d6g7h8i9j0k1` | Add kas denomination + payment fields to laporan_shift |
| `e7h8i9j0k1l2` | Redesign penyetoran (per-shift model) + add penyetoran_batch table |
| `f0i1j2k3l4m5` | Add status + approval fields to expenses (StatusExpense enum, 11 audit cols) |
| `g1j2k3l4m5n6` | Add status + approval fields to penerimaan (StatusPenerimaan enum, 9 audit cols) |

## Environment Variables

```env
DATABASE_URL=postgresql+asyncpg://spbu:dev_password@localhost:5432/spbu_manager
SECRET_KEY=...
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
STORAGE_TYPE=local
STORAGE_PATH=/app/storage
ENVIRONMENT=development
TIMEZONE=Asia/Jakarta
MAX_UPLOAD_SIZE_MB=10
```

## Deployment Notes

- Port **8006** (port 8000 dipakai project lain di server yang sama)
- PM2: `uvicorn app.main:app --host 127.0.0.1 --port 8006`
- **bcrypt**: server pakai `bcrypt==4.0.1` — jangan upgrade ke 5.x (passlib 1.7.4 tidak kompatibel)
- DB production: PostgreSQL 12, tabel prefix `master_` (misal `master_user`)
