# NEO ERP Sales API

REST API khusus modul penjualan untuk aplikasi desktop, mobile, dan web.

## Teknologi

- Python 3.11+
- FastAPI
- SQLAlchemy async + asyncmy
- MariaDB 10.11

Database menggunakan schema NEO ERP yang sudah ada. API tidak menjalankan migrasi
atau perubahan struktur database.

## Menjalankan lokal

1. Buat virtual environment dan aktifkan.
2. Install project beserta dependency development:

	```powershell
	python -m pip install -e ".[dev]"
	```

3. Salin `.env.example` menjadi `.env`, lalu isi koneksi MariaDB.
4. Jalankan API:

	```powershell
	uvicorn app.main:app --reload
	```

Dokumentasi OpenAPI tersedia di `http://127.0.0.1:8000/docs`.

## Deploy ke IDCloudHost Elite Pro / cPanel

Gunakan [panduan deploy cPanel](docs/deploy-idcloudhost-elite-pro.md) untuk pola
upload ZIP dan **Setup Python App**, tanpa Docker atau VPS.

```powershell
.\scripts\build_cpanel_package.ps1
```

Upload `deploy\neoerp-api-cpanel.zip`, pilih startup file `passenger_wsgi.py`
dan entry point `application`, install `requirements.txt` pada virtualenv
hosting, lalu isi `.env` dari `deploy\.env.cpanel.example` dan restart aplikasi.
Adapter menjalankan lifecycle FastAPI agar session database tersedia.
Jika install melalui project, gunakan `python -m pip install ".[hosting]"`.
Template credential wajib diganti; ketiga flag write tetap nonaktif pada
deployment pertama. Untuk VPS/Docker, lihat [deployment](docs/deployment.md).

## Mencoba list penjualan di browser

Pastikan API sedang berjalan, lalu buka salah satu URL berikut:

- Health check: <http://127.0.0.1:8000/health>
- List Agustus 2026: <http://127.0.0.1:8000/api/penjualan?month=8&year=2026>
- Hanya dokumen belum tutup buku: <http://127.0.0.1:8000/api/penjualan?month=8&year=2026&belum_tutup_buku=true>
- Halaman kedua, 10 data per halaman: <http://127.0.0.1:8000/api/penjualan?month=8&year=2026&page=2&page_size=10>
- Form interaktif Swagger: <http://127.0.0.1:8000/docs#/penjualan/list_sales_api_penjualan_get>

Parameter `GET /api/penjualan`:

| Parameter | Wajib | Nilai | Default |
|---|---|---|---|
| `month` | Tidak | `1` sampai `12` | Bulan saat ini |
| `year` | Tidak | `2000` sampai `2100` | Tahun saat ini |
| `belum_tutup_buku` | Tidak | `true` atau `false` | `false` |
| `page` | Tidak | Minimal `1` | `1` |
| `page_size` | Tidak | `1` sampai `100` | `25` |

Contoh bentuk respons:

```json
{
	"data": [
		{
			"kodesi": "SI260801000120",
			"tanggal_faktur": "2026-08-08",
			"nama_pelanggan": "...",
			"total": "...",
			"tutup": false
		}
	],
	"pagination": {
		"page": 1,
		"page_size": 25,
		"total": 120,
		"total_pages": 5
	}
}
```

Untuk mencoba melalui Swagger, buka URL `/docs`, pilih `GET /api/penjualan`,
tekan **Try it out**, isi parameter, lalu tekan **Execute**.

## Endpoint tersedia

### Pencarian barang per kata

Parameter `q` pada lookup barang penjualan dan order penjualan mencocokkan
setiap kata pada kode atau nama barang, tanpa membedakan huruf besar/kecil dan
tanpa mengharuskan urutan kata. Semua kata harus cocok; spasi berlebih diabaikan.
Contoh: `q=Rotak Hogama` maupun `q=Hogama Rotak` menemukan
`ROTAK Beat FI @100 (hogama)`. Filter barang aktif, lokasi stok, dan pagination
tetap berlaku.

### Identitas pengguna Sales

Login melalui `mspengguna` tetap memakai password dan `kodepengguna` akun
tersebut untuk identitas serta audit. Jika hak aksesnya `SALES`, `namapengguna`
harus menunjuk ke kode atau nama sales aktif pada `mssales`. API memasukkan
`kodesales` hasil pemetaan itu sebagai `kode_sales` pada profil dan JWT, bukan
memakai `kodepengguna` sebagai kode sales.

Contoh: akun `U00004` dengan nama pengguna `SLS013` menghasilkan
`user_code=U00004`, `source=PENGGUNA`, dan `kode_sales=SLS013`. Filter penjualan,
pelanggan, dan piutang menggunakan `kode_sales`. Akun Sales tanpa pemetaan ke
sales aktif ditolak dengan HTTP 403 dan pesan konfigurasi yang jelas.
Sesudah perubahan ini, restart proses API bila tidak memakai auto-reload dan
logout/login ulang pada aplikasi agar JWT lama diganti.

- `GET /health`
- `POST /api/auth/login` (alias: `POST /api/login`)
- `GET /api/auth/me` (alias: `GET /api/me`)
- `POST /api/auth/logout` (alias: `POST /api/logout`)
- `GET /api/pelanggan?aktif=AKTIF&search=nama`
- `GET /api/pelanggan/{kodepelanggan}`
- `GET /api/pelanggan/number/preview?nama=nama`
- `GET /api/pelanggan/master/{jenis-outlet|sales|jalur|tingkat-harga|call-code}`
- `GET /api/pelanggan/{kodepelanggan}/piutang-summary`
- `GET /api/pelanggan/notif-piutang?sampai_tanggal=2026-08-16`
- `POST /api/pelanggan/notif-piutang/preview-text`
- `GET /api/pelanggan/{kodepelanggan}/whatsapp`
- `POST /api/pelanggan/notif-piutang/whatsapp-link`
- `POST /api/pelanggan`
- `PUT /api/pelanggan/{kodepelanggan}`
- `DELETE /api/pelanggan/{kodepelanggan}`
- `GET /api/msperusahaan`
- `GET /api/msperusahaan/report-profile`
- `GET /api/msperusahaan/ppn`
- `GET /api/msperusahaan/qty-decimal-places`
- `GET /api/msperusahaan/schema-version`
- `GET /api/penjualan?month=8&year=2026`
- `GET /api/penjualan/{kodesi}`
- `GET /api/penjualan/master/pelanggan?q=nama`
- `GET /api/penjualan/master/sales?q=nama`
- `GET /api/penjualan/master/lokasi?kode_divisi=01`
- `GET /api/penjualan/master/barang?q=nama&kode_lokasi=G01`
- `GET /api/penjualan/master/barang/{kodebarang}/satuan?kode_tingkat=T00001`
- `GET /api/penjualan/master/barcode/{barcode}?kode_tingkat=T00001&kode_lokasi=G01`
- `GET /api/penjualan/pelanggan/{kodepelanggan}/credit-info`
- `GET /api/penjualan/pelanggan/{kodepelanggan}/barang/{kodebarang}/price-history`
- `GET /api/penjualan/pelanggan/{kodepelanggan}/receivable-summary?tanggal_mulai=2026-08-01&tanggal_selesai=2026-08-31`
- `GET /api/order-penjualan?month=8&year=2026`
- `GET /api/order-penjualan/{kodeso}`
- `POST /api/order-penjualan`
- `PUT /api/order-penjualan/{kodeso}`
- `DELETE /api/order-penjualan/{kodeso}`
- `GET /api/order-penjualan/number/preview`
- `GET /api/order-penjualan/{kodeso}/history`
- `GET /api/order-penjualan/{kodeso}/for-penjualan`
- `POST /api/order-penjualan/auto-from-shortage`
- `GET /api/order-penjualan/master/pelanggan`
- `GET /api/order-penjualan/master/sales`
- `GET /api/order-penjualan/master/lokasi`
- `GET /api/order-penjualan/master/barang`
- `GET /api/order-penjualan/master/barang/{kodebarang}/satuan`
- `GET /api/order-penjualan/master/barcode/{barcode}`
- `GET /api/order-penjualan/pelanggan/{kodepelanggan}/credit-info`
- `GET /api/order-penjualan/pelanggan/{kodepelanggan}/receivable-summary`

Endpoint list transaksi dan lookup pelanggan/barang mendukung pagination. Lookup
barcode mengembalikan HTTP `409` jika barcode lama terhubung ke lebih dari satu
barang, sehingga client tidak memilih barang secara acak.

Endpoint `msperusahaan` saat ini read-only. Operasi write (`POST`/`PUT`/`PATCH`/
`DELETE`) untuk perusahaan belum diaktifkan.

Endpoint write pelanggan memakai pengaman `ENABLE_CUSTOMER_WRITE_API` dan
mensyaratkan tabel `mspelanggan` menggunakan InnoDB. Flag ini terpisah dari
`ENABLE_WRITE_API`, sehingga CRUD pelanggan dapat diaktifkan tanpa membuka write
penjualan. Backup dan hasil migrasi dicatat di
[`docs/migration_20260816_mspelanggan_innodb.md`](docs/migration_20260816_mspelanggan_innodb.md).

Endpoint write order penjualan memakai pengaman `ENABLE_ORDER_WRITE_API` (atau `ENABLE_WRITE_API`)
dan mensyaratkan tabel order (`trsoheader`, `trsodetail`, `trfifo_layer`, `xyz`) menggunakan
InnoDB. Flag ini dapat diaktifkan terpisah dari `ENABLE_WRITE_API`.

Schema order saat ini tidak menyimpan sales, divisi, rute, lokasi header, atau
tanggal faktur. Endpoint draft hanya mengembalikan field order yang benar-benar
tersedia dan menggunakan `tanggalcreated` sebagai `tanggal_order`.

## Phase 2: write API

Endpoint berikut sudah diimplementasikan dan terverifikasi, tetapi
**dinonaktifkan secara default** sampai pengujian database nonproduksi selesai:

- `POST /api/penjualan`
- `PUT /api/penjualan/{kodesi}`
- `DELETE /api/penjualan/{kodesi}`

Saat autentikasi aktif, semua endpoint `/api` membutuhkan Bearer token dan
identitas audit diambil dari principal token. Update juga membutuhkan
`expected_tanggal_edit` dari respons detail terakhir. Jika dokumen sudah berubah,
API mengembalikan HTTP `409`.

Empat tabel transaksi telah dimigrasikan dari MyISAM ke InnoDB pada 2026-08-09.
Hasil backup dan verifikasi dicatat di `docs/migration_20260809_innodb.md`. API
tetap memiliki dua pengaman:

1. `ENABLE_WRITE_API=false` pada `.env` menghasilkan HTTP `503`.
2. Saat flag diaktifkan, API menghasilkan HTTP `503` jika ada tabel transaksi
	yang bukan InnoDB.

Skrip migrasi tersimpan di `docs/sql/migrate_sales_tables_to_innodb.sql`.
`ENABLE_WRITE_API` tetap `false`; jangan mengaktifkannya sebelum integration test
pada database nonproduksi selesai.

## Phase 3: FIFO dan locking

Create dan update memanggil `sp_fifo_post_penjualan` dalam transaksi command,
menghitung `hbkonversi` dari konsumsi FIFO, dan menyinkronkan saldo hanya untuk
pasangan barang/lokasi yang tersentuh. Shortage menghasilkan HTTP `409`; API tidak
membuat stok atau layer FIFO sintetis.

Endpoint Phase 3:

- `POST /api/penjualan/{kodesi}/lock`
- `POST /api/penjualan/{kodesi}/unlock`
- `POST /api/penjualan/lock/batch`
- `POST /api/penjualan/{kodesi}/fifo/developer-repost`
- `POST /api/order-penjualan/auto-from-shortage`

Semua endpoint membutuhkan principal terautentikasi. Unlock hanya menerima role
`SUPERVISOR` atau `OWNER`; developer repost hanya menerima `OWNER`. Batch lock
dibatasi maksimal 31 hari dan berjalan atomik dalam satu
transaksi. Developer repost menghitung defisit per barang/lokasi dan hanya
membuat layer sintetis `PENJUALAN-DEV` sebesar defisit. Operasi gagal bila HPP
sintetis tidak dapat ditentukan dari konsumsi dokumen atau layer terakhir.

Auto-order menghitung stok langsung dari layer FIFO yang masih terbuka, bukan
dari nilai saldo cache. Hanya qty selisih yang ditulis ke `trsodetail`; potongan
dibagi proporsional dan subtotal, pajak, serta total dihitung ulang oleh server.
Jika seluruh stok cukup, endpoint menghasilkan HTTP `409`.

Contoh payload minimum auto-order:

```json
{
	"tanggal_order": "2026-08-09",
	"kode_pelanggan": "A001",
	"kode_divisi": "000001",
	"items": [
		{
			"kode_barang": "2506-00073",
			"qty": 10,
			"satuan": "PCS",
			"harga_satuan": 3780,
			"konversi": 1,
			"kode_lokasi": "000001"
		}
	]
}
```

Verifier FIFO nyata menggunakan dokumen unlocked yang sudah memiliki konsumsi,
kemudian membatalkan seluruh perubahan melalui transaksi koneksi luar:

```powershell
python -m scripts.verify_phase3_fifo_rollback
python -m scripts.verify_phase3_remaining_rollback
```

Verifier kedua memaksa shortage developer dan membuat auto-order pada schema
nyata, lalu membuktikan layer sintetis, SO, detail, dan audit kembali nol setelah
rollback transaksi luar.

## Phase 4: operasional

Endpoint read-only berikut tetap tersedia saat `ENABLE_WRITE_API=false`:

- `GET /api/penjualan/number/preview?periode=202608&kode_divisi=000001`
- `GET /api/penjualan/number/reusable?periode=202608`
- `GET /api/penjualan/number/missing?periode=202608&kode_divisi=000001`
- `GET /api/penjualan/fifo/shortage-report?tanggal_mulai=2026-08-01&tanggal_selesai=2026-08-31`
- `GET /api/penjualan/{kodesi}/history`
- `GET /api/penjualan/{kodesi}/print/invoice`
- `GET /api/penjualan/{kodesi}/print/surat-jalan`
- `GET /api/penjualan/{kodesi}/wa-payload`

`periode` memakai format `YYYYMM`. Preview bersifat informatif; nomor final tetap
dibuat di dalam transaksi create. Missing number dihitung per prefix divisi dan
dibatasi 500 hasil. Laporan shortage membandingkan kebutuhan detail dalam unit
dasar dengan konsumsi FIFO yang sudah terbentuk.

Endpoint operasional berikut membutuhkan principal terautentikasi:

- `POST /api/penjualan/status/reconcile`
- `PATCH /api/penjualan/{kodesi}/ekspedisi`
- `PATCH /api/penjualan/{kodesi}/koli`

Reconcile menerima `tanggal_mulai`, `tanggal_selesai`, dan `preview_only` pada
body. Mode preview tetap berjalan saat write API mati; mode apply dan kedua PATCH
menghasilkan HTTP `503` selama `ENABLE_WRITE_API=false`. Semua perubahan ditulis
ke audit `xyz`.

Payload invoice memakai `vwinvoices2`, surat jalan memakai `vwinvoices`, dan WA
mengembalikan nomor tujuan serta message tanpa mengirim pesan. Jika `mssetupwa`
kosong, API memakai format pesan bawaan.

Verifier write Phase 4 memakai savepoint internal dan transaksi luar yang selalu
rollback:

```powershell
python -m scripts.verify_phase4_rollback
```

Verifier Phase 2:

```powershell
python -m scripts.verify_phase2_rollback
python -m scripts.verify_phase2_lifecycle
```

Verifier pertama menguji seluruh SQL command lalu rollback. Verifier kedua
menjalankan lifecycle create/update/delete dengan fixture periode 2099 dan selalu
membersihkan SI, SO, serta audit uji. Verifier lifecycle Phase 2 memakai FIFO
no-op karena posting FIFO nyata diuji terpisah oleh verifier Phase 3. Hasil
terakhir: create `saved`, update `updated`, delete
`deleted_via_fifo_procedure`, dan seluruh fixture tersisa `0`.

## Pengujian

```powershell
python -m pytest -q
```

Integration command harus memakai database terisolasi yang berbeda dari
`DB_NAME` dan namanya wajib berakhiran `_test`:

```powershell
$env:TEST_DB_NAME = "neoerp_test"
$env:TEST_DB_HOST = "127.0.0.1"
$env:TEST_DB_PORT = "3307"
$env:TEST_DB_USER = "root"
# Atur TEST_DB_PASSWORD melalui secret environment lokal/CI.
python -m scripts.provision_test_database
python -m pytest -m integration -q
python -m scripts.verify_test_database
```

`scripts.provision_test_database` membuat 14 tabel dan hanya menyalin fixture
master minimum yang dibutuhkan lifecycle. Script menolak target yang sudah ada
dan menghapus schema parsial jika provisioning gagal. Password test jangan
ditulis ke repository; gunakan environment lokal atau secret manager CI.

## Keamanan dan operasional

Production mewajibkan `APP_DEBUG=false`, `AUTH_ENABLED=true`, API key terkonfigurasi,
dan `TRUSTED_HOSTS` tanpa wildcard. `AUTH_API_KEYS_JSON` hanya menyimpan hash
SHA-256 token dengan `user_code`, `user_name`, dan role `VIEWER`, `SALES`,
`SUPERVISOR`, atau `OWNER`. Token mentah dikirim melalui
`Authorization: Bearer <token>`.

Header lama `X-User-Code`, `X-User-Name`, dan `X-User-Role` hanya tersedia untuk
development saat autentikasi dinonaktifkan. Jangan gunakan mode tersebut di
production.

- `/health` adalah liveness check publik.
- `/health/ready` adalah readiness check publik yang menguji koneksi database.
- Response memiliki `X-Request-ID`; ID valid dari client akan dipertahankan.
- Log request berbentuk JSON tanpa body, credential, atau query SQL.
- Error database dipetakan terpusat ke HTTP `409`, `503`, atau `500` tanpa
	membocorkan detail SQL.

Konfigurasi container production dan prosedur deployment dijelaskan di
[docs/deployment.md](docs/deployment.md). `ENABLE_WRITE_API` tetap `false` secara
default.

Panduan koneksi emulator/perangkat, Bearer token, Retrofit/OkHttp, DTO Kotlin,
error handling, dan alur penjualan Android tersedia di
[docs/android.md](docs/android.md).

Spesifikasi bisnis lengkap berada di [API_Penjualan](API_Penjualan).