# Project Overview: `ods-apps` (SHD Marketplace)

A **Laravel 12 modular monolith** for sales/ordering with a mobile (Android) GPS-tracking component, containerized via Docker.

## Infrastructure (docker-compose.yml)
- **nginx** (`:8007` → 80) — reverse proxy + PHP-FPM link
- **php-fpm** (PHP 8.2 alpine, custom build in `config/php82`)
- **mysql** (`:4207` → 3306, db `db_shd`)
- **phpmyadmin** (`:1007` → 80)
- Network: `app-shdm`, Volume: `shdm-app`

## Tech Stack
- Laravel 12 / PHP 8.2
- `nwidart/laravel-modules` (modular architecture)
- `laravel/sanctum` + `firebase/php-jwt` (API auth, mobile)
- `yajra/laravel-datatables-oracle` (server-side datatables)
- `barryvdh/laravel-dompdf`, `mPDF`, `milon/barcode` (PDF + barcode printing)
- `sweetalert2/laravel` (UI alerts)

## Module Map (nwidart/laravel-modules)
| Module | Domain | Key Controllers |
|---|---|---|
| **Sistem** | RBAC, users, menus | `UserController`, `RoleController`, `MenuController`, `SubMenuController` |
| **Master** | Products, stock, officers | `ProductController`, `ProductTypeController`, `ProductUnitController`, `StockMaterialController`, `StockProductController`, `MainProductController`, `JabatanController`, `PetugasController` |
| **Customer** | Customer registration & geo-maps | `CustomerController`, `CustomerMapsController` |
| **PurchaseOrder** | Sales order lifecycle | `PurchaseOrderController`, `OrderListController` (verifikasi), `InvoiceListController` (faktur) |
| **OfficerActivity** | GPS tracking | `GPSTrackerOfficerController`, `OfficerActivity*` |

## Core Models (`app/Models/`)
`User`, `Role`, `UserMenu`, `UserMenuSub`, `UserMenuAccess` (3-level RBAC), `LogActivityUser`

## Flow Diagram

### A. Web Flow (session-based)
```
nginx (:8007) → web.php
  /              → AuthController@index  [guest + prevent-back]
  /auth (POST)   → AuthController@login
                   → Auth::attempt → check status=1
                   → session(id, nip, sex, username, role_id, role_access, name, photo)
                   → redirect /dashboard
  /dashboard     → DashboardController@index  [auth]
                   → build 3-level menu tree via user_menu_access + user_menu_sub
                   → counters: customers, products, orderTrx (draft/process/done)
  /sistem/*      → Menu/SubMenu/Role/User CRUD  (auth, MD5-hashed route names)
  /master/*      → Product, Stock, Jabatan, Petugas CRUD
  /customer/*    → Customer CRUD + maps
  /purchaseorder/* → Verifikasi (OrderList) + Invoice (Faktur) + cetak surat-jalan/faktur
  /officeractivity/* → GPS Tracker (manager view)
  /logout        → AuthController@logout
```

### B. Mobile API Flow (Sanctum + Firebase JWT) — `routes/api.php`
```
POST /api/login  → AuthApiController@login
                   → email+password check → Hash::check
                   → delete old tokens → createToken('mobile-app')
                   → Firebase custom JWT (RS256) with claims: role, userId
                   → returns: { sanctum token, firebase_token, user {role: officer|manager} }
POST /api/logout (auth:sanctum)
GET  /api/profile (auth:sanctum)

[Officer]
POST /api/tracking/point  → every ~5s from Android
                             → insert TrackPoint (lat/lon/accuracy/speed/heading/device_timestamp)
                             → upsert TrackingSummary (daily):
                               total_points, distance (haversine), max_speed,
                               avg_speed (running avg), start_time, end_time
POST /api/tracking/batch  → batch upload
GET  /api/tracking/history         → own points
GET  /api/tracking/history/{date}  → own points by day

[Manager]
GET  /api/tracking/manager/summary        → all officers summary
GET  /api/tracking/manager/points/{userId} → specific officer points

[Customer]
GET  /api/generated-kodecust / /api/customer-list / /api/customer-maps
POST /api/customer-save
```

## RBAC Mechanism
- `user_menu` (level 1) → `user_menu_sub` (level 2, `parent_id` nullable for level 3) → `user_menu_access` (role_id, menu_id, sub_menu_id as JSON array)
- Helpers in `app/Helpers/helper.php`: `cek_akses()`, `cek_submenu()`, `LogActivity()`, `idr()`, `terbilang()`, `pembulatan()`, `tgl_indonesia()`, `angka_minus()`, `getBrowserName()`
- Routes use `md5()` on URLs (e.g. `md5('/menu/simpan')`) to obfuscate form actions

## Mobile ↔ Web Sync
- Officer GPS data lands in `track_points` + `tracking_summaries` (OfficerActivity module)
- Managers view officer routes via web `/officeractivity/tracker` → `GPSTrackerOfficerController` rendering tracking summary + per-user route
- Sales/orders flow is web-only (`/purchaseorder/verifikasi` → `cetakSuratJalan`, `cetakFaktur`)

## Key Fixes — GPS Tracker Duration, Route Detail, Online Status

### Duration Column Bug
- **File:** `Modules/OfficerActivity/app/Http/Controllers/GPSTrackerOfficerController.php`
- **Root cause:** `start_time`/`end_time` are Unix-second timestamps from arbitrary dates. `Carbon::parse()` treated them as seconds → anomalous year-like values; direct subtraction `(end - start) * 1000` produced year-spanning diffs.
- **Fix:** `timeOfDayDiffMs($startSec, $endSec)` extracts time-of-day with `% 86400` modulo on both timestamps, handles overnight wrap by adding 24h. Returns correct hours/minutes.
- **Output format:** `"X jam Y mnt"` via `formatDuration(int $ms)`.

### Route Detail — Start/Finish Pins
- **File:** `Modules/OfficerActivity/resources/views/gps-tracker/js.blade.php`
- Custom `buildPinIcon(color, label)` function generates canvas-based pins.
- **Start pin:** green (`#16a34a`, label "S") with InfoWindow showing time, coords, speed.
- **Finish pin:** red (`#dc2626`, label "F") with InfoWindow showing time, coords, speed.
- **Stationary pin:** blue (`#2563eb`, label "P") with InfoWindow showing coords + "Tidak bergerak" status.

### Route Detail — Info Panel & Polyline
- **File:** `Modules/OfficerActivity/resources/views/gps-tracker/index.blade.php`
- 7 summary tiles: Start time, Finish time, Distance (km), Duration, Avg Speed, Max Speed, Points (GPS samples).
- Smooth blue polyline (`#1d4ed8`, weight 5) with white chevron arrows indicating travel direction.
- Map legend: Start 🟢, Finish 🔴, Path ───, Tidak Bergerak 🔵 (shown only on stationary days), fitted to bounds on load.

### Points Column Clarification
- `total_points` = count of GPS samples recorded during the trip (not a reward currency).
- Tooltip on Points column header: *"Titik rute setelah dedup 50m / total GPS terekam."*
- On stationary days, Points tile shows `1` regardless of actual recorded count.

### Route API Endpoint (`route()` method)
- Returns rich JSON payload: `{ officer, date, stationary, summary{...}, start, end, points[] }`
- Summary includes: `distance_km`, `duration_label`, `avg_speed_kmh`, `max_speed_kmh`, `start_at`(ms), `end_at`(ms), `total_points`
- `stationary: true` flag is set when device bounding box diagonal ≤ 500m.
- When `stationary: true`, summary values are zeroed (distance=0, duration=-, avg=0, max=0, start_at=null, end_at=null, total_points=1).
- Helper `formatDuration(int $ms): string` formats MS to `"X jam Y mnt"`.

### Timestamp Normalization & Date Filtering
- **File:** `Modules/OfficerActivity/app/Http/Controllers/GPSTrackerOfficerController.php`
- **Root cause:** `device_timestamp` values are Unix seconds from arbitrary calendar dates (batch-uploaded offline data). `track_date` is server-assigned at upload time. Browser timezone (WIB, UTC+7) caused normalized timestamps near midnight UTC to display as next-day dates.
- **Fix — `normalizeTs($trackDate, $ts)`:** Extracts time-of-day (UTC) from raw Unix-second timestamp, anchors it to `track_date` midnight UTC, returns ms. All timestamps land on `track_date` in UTC.
- **Fix — `route()` filter:** After normalization, points are filtered to `[dayStartMs, dayEndMs)` window so only timestamps within `track_date` UTC 00:00–23:59:59 are included. `start_at`/`end_at`/`total_points` in the payload are derived from the filtered points (not DB summary).
- **Fix — `fmtTime()` JS:** Uses `timeZone: 'UTC'` so normalized timestamps display in UTC (matching `track_date`), not browser local time (which would shift to next day for late-night UTC times).
- **Result:** Start/Finish always show date equal to header `track_date`. Points count in route modal matches filtered points. Duration is accurate.

### Stationary Detection — Summary Table & Route Modal
- **File:** `Modules/OfficerActivity/app/Http/Controllers/GPSTrackerOfficerController.php`
- **Root cause:** Device stayed in one place all day but `tracking_summaries` recorded inflated distance (26 km) and avg/max speeds from GPS drift scatter.
- **Fix — `summary()` JOIN:** Adds `LEFT JOIN` to a subquery computing `MIN`/`MAX` lat/lng per `(user_id, track_date)`. New `isStationary($row)` helper computes bounding box diagonal via `haversineM()`; ≤ 500 m = stationary.
- **Fix — column overrides:** When stationary → Distance = 0, Duration = `-`, Avg = 0, Max = 0, Points = 1. Otherwise normal values.
- **Fix — `route()` payload:** `isStationaryPoints($points)` runs the same 500m threshold on the actual track points. When stationary → distance=0, duration=-, avg=0, max=0, start_at=null, end_at=null, total_points=1.
- **Result:** Stationary days show clean "0 km" / "-" / 0 km/h in the summary table and a single blue location pin in the route modal. No more fake 26km from GPS drift.

### Route Map Deduplication — No Starburst
- **File:** `Modules/OfficerActivity/resources/views/gps-tracker/js.blade.php`
- **Root cause:** GPS drift creates starburst pattern of overlapping zigzag lines. Centroid-based or last-kept-point deduplication failed when device made a round trip (home → destination → home) because home cluster dominated.
- **Fix — `deduplicatePath(rawPoints, minDistM=150, stationaryDiagM=500)`:**
  - **Step 1** — Bounding box diagonal of ALL points. ≤ 500m → stationary → return single median point with `stationary: true` flag.
  - **Step 2** — Anchor-based dedup: threshold = `max(150m, accuracy × 2)` per candidate point. Anchor only moves on real displacement. First and last raw points always preserved for Start/Finish pins.
- **Fix — `renderRoute()`:** Branches on `isStationary`. Moving → polyline + green S / red F pins. Stationary → single blue P pin at zoom 17.
- **Result:** Starburst pattern eliminated. Round-trip routes show clean out-and-back waypoint lines. Stationary days show 1 blue pin at the median location.

### Online Status — Stale Detection
- **File:** `Modules/OfficerActivity/resources/views/gps-tracker/js.blade.php`
- **Root cause:** Firebase `onDisconnect()` only fires on graceful disconnect. Hard device shutdown / battery pull / airplane mode leaves `online: true` stuck in Firebase. Web dashboard shows "Online" forever.
- **Fix — `isOfficerOnline(o)`:** Returns true only if BOTH `online: true` AND `lastUpdate` is no older than **90 seconds** (≈ 18 missed 5-second GPS pings).
- **Fix — periodic 30s re-render:** `setInterval` re-paints markers (green → grey), sidebar list, modal list, stat cards, and modal header badges every 30 seconds. Re-evaluates `isOfficerOnline()` even when Firebase data has not changed.
- **Result:** Device that stops pinging for >90s flips to Offline within 30s. No more stuck "Online" indicators after a device dies.

## Entry Points
- `src/artisan`, `src/running.sh`
- Web: `http://localhost:8007`
- DB: `localhost:4207` (root/root)
- phpMyAdmin: `http://localhost:1007`
