Files
aars/README.md
T

457 lines
16 KiB
Markdown

# AARS - Attendance & Accident Record System
出勤及意外記錄系統
## 🚀 快速開始
### 方式一:Docker 部署(推薦)
```bash
# 复制到 VPS
scp -r ~/aars root@187.127.116.15:/root/
# SSH 到 VPS
ssh root@187.127.116.15
# 進入目录并运行
cd /root/aars
chmod +x setup.sh
./setup.sh
```
访问:`http://187.127.116.15:18775`
### 方式二:本地开发
```bash
# Backend
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python test_backend.py # 测试
uvicorn main:app --reload --port 8000
# Frontend (另一个terminal)
cd frontend
npm install
npm run dev
```
## 🔐 默认账户
- **Email:** admin@aars.hk
- **Password:** admin123
⚠️ 首次登录后请立即修改密码!
## 📋 功能
### 出勤 Attendance
- ✅ 列表视图 (Filter/Sort/分页)
- ✅ 新增/编辑/删除记录
- ✅ Excel 批量导入(自动去重)
- ✅ 导出 Excel / PDF
- ✅ 自定义字段
### 意外 Accident
- ✅ 列表视图 (Filter/Sort/分页)
- ✅ 新增/编辑/删除记录
- ✅ Excel 批量导入(自动去重)
- ✅ 导出 Excel / PDF
- ✅ 自定义字段
### Dashboard
- 📊 统计卡片(总计/今日/本月)
- 📈 趋势图表
- ⚠️ 最近意外列表
## 🏗️ 技术栈
| Layer | Technology |
|-------|------------|
| Frontend | React 18 + Vite + TailwindCSS |
| Backend | FastAPI + SQLAlchemy |
| Database | SQLite |
| Charts | Recharts |
| Excel | openpyxl + xlsxwriter |
| PDF | ReportLab |
## 📁 项目结构
```
aars/
├── backend/
│ ├── main.py # FastAPI app
│ ├── models.py # Database models
│ ├── schemas.py # Pydantic schemas
│ ├── auth.py # JWT auth
│ ├── database.py # SQLite setup
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── pages/ # React pages
│ │ ├── components/ # Reusable components
│ │ └── context/ # Auth context
│ └── ...
├── docker-compose.yml
├── Dockerfile
└── SPEC.md
```
## 🔧 配置
### 端口
- 默认: `18775`
- 修改: 编辑 `docker-compose.yml`
### JWT Secret
- 开发: 自动生成
- 生产: 修改 `backend/secret.py`
## 📝 API 文档
启动后访问:`http://localhost:8000/docs` (Swagger UI)
## 🐛 常见问题
**Q: Docker build 失败?**
```bash
docker compose down
docker compose build --no-cache
docker compose up -d
```
**Q: 无法登录?**
检查浏览器 console 的 CORS 错误,或确认 JWT secret 未变更。
**Q: Excel 导入失败?**
确保 Excel 文件第一行是表头,且日期格式为 `YYYY-MM-DD`
## 📊 Attendance System
### Data Source
- **Primary**: SQLite table `attendance_records` (SQLAlchemy ORM)
- **Legacy**: Excel file at `/app/data/attendance.xlsx` (still saved on upload for export compatibility)
### DB Schema (`attendance_records`)
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | auto |
| `employee_name` | VARCHAR(255) | index |
| `company`, `department` | VARCHAR(100) | nullable |
| `date` | DATE | index |
| `weekday` | VARCHAR(20) | e.g. "Friday" |
| `check_in`, `check_out` | DATETIME | nullable |
| `shift_code` | VARCHAR(20) | e.g. "S7a" |
| `status_code` | VARCHAR(20) | `normal` / `late` / `early` / `late_early` / `late_ot` / `early_ot` / `ot` / `missing` / `abnormal` |
| `status_text` | VARCHAR(100) | Chinese e.g. 「遲到46分 / OT60分」 |
| `late_minutes`, `early_minutes`, `ot_minutes` | INTEGER | computed |
| `expected_in`, `expected_out`, `actual_in`, `actual_out` | VARCHAR(10) | "HH:MM" string |
| `is_manually_edited` | BOOLEAN | **preserves manual edits from import overwrite** |
| `raw_data` | JSON | source row |
| `shift_id` | INTEGER FK | → `shifts.id` |
UNIQUE constraint on `(employee_name, date)` — one record per staff per day.
### Shifts Table (`shifts`)
- 11 rows pre-loaded (S7a, S8a, ...)
- Columns: `shift_code` + per-day (`mon_start`/`mon_end` ... `sun_start`/`sun_end`), all `HH:MM` strings
- Method: `Shift.get_schedule(weekday)``(start_str, end_str)` or `(None, None)` on day off
### Status Calculation (`calculate_attendance_status` in `main.py`)
| Status | Rule |
|--------|------|
| `missing` | No check-in or no check-out |
| `abnormal` | check_in == check_out (exact same second) |
| `late` | check_in > expected start (late_minutes = round(diff)) |
| `early` | check_out < expected end (early_minutes = round(diff)) |
| `ot` | check_out > expected end (ot_minutes = round(diff)) |
| `late_early`, `late_ot`, `early_ot` | combined codes |
Notes:
- `int()` was buggy → now uses `round()` (off-by-one fix)
- `18:00:00` exact default check (NOT `18:00:03` which is real swipe)
### API Endpoints (current, attendance scope)
| Method | Path | Source | Notes |
|--------|------|--------|-------|
| GET | `/api/dashboard/summary` | **SQL** | Returns `total_records`, `*_count`, `staff_count`, `by_status`, `by_department` (frontend-compatible fields) |
| GET | `/api/attendance/stats` | **SQL (alias)** | Per-employee: count + minutes + avg + max + consecutive_missing + last_attendance_date + attendance_rate |
| GET | `/api/attendance/records` | **SQL (alias)** | Supports `staff`, `date_from`, `date_to`, `status`, `search`, `sort_by`, `sort_order`, `page`, `per_page` |
| GET | `/api/attendance/{id}` | SQL | Single record by ID |
| PUT | `/api/attendance/{id}` | SQL | Update + mark `is_manually_edited=true` (protected from import) |
| POST | `/api/upload/attendance` | Excel + SQL | Save file + import to DB (skips records where `is_manually_edited=true`) |
| GET | `/api/export/attendance/excel` | **SQL → xlsxwriter** | On-the-fly 16-col xlsx with status colors |
### Excel Export Columns (16)
| Col | Name | Notes |
|-----|------|-------|
| A | Staff Name | |
| B | Company | |
| C | Department | |
| D | Date | ISO `YYYY-MM-DD` |
| E | Weekday | |
| F | Shift | |
| G | Expected In | |
| H | Expected Out | |
| I | Actual In | |
| J | Actual Out | |
| **K** | **Status Code** | **color-coded per status** |
| L | Status Text | Chinese |
| M | Late Min | |
| N | Early Min | |
| O | OT Min | |
| P | Manual Edit | "Yes" if manually edited |
### Status Colors (xlsxwriter)
| Status | BG | Font |
|--------|------|------|
| Header | `#DBEAFE` (light blue) | `#1E3A8A` (dark blue, bold) — frozen top row |
| normal | `#D1FAE5` (green) | `#065F46` |
| late / late_early | `#FEF3C7` (amber) | `#92400E` |
| late_ot | `#FED7AA` (orange) | `#7C2D12` |
| early | `#DBEAFE` (blue) | `#1E40AF` |
| early_ot | `#E0E7FF` (indigo) | `#1E40AF` |
| ot | `#EDE9FE` (purple) | `#5B21B6` |
| abnormal | `#FEE2E2` (red) | `#7F1D1D` |
| missing | `#E5E7EB` (gray) | `#374151` |
### Frontend Components
- **Dashboard** (`pages/Dashboard.jsx`):
- 8 stats cards (total / normal / late / early / OT / missing / abnormal / staff)
- 4 leaderboards (Late / OT / Missing / Attendance Rate)
- 5 date presets (今日 / 本週 / 上週 / 本月 / 清除)
- Per-staff detail row: avg / max / consecutive_missing / last_attendance_date / attendance_rate
- **Attendance List** (`pages/attendance/List.jsx`):
- Table with filters (status, date range, staff, search)
- Pagination + sort
- "Dashboard" back-link button (`to="/"`, NOT `to="/dashboard"`)
- **Attendance Detail** (`pages/attendance/Detail.jsx`):
- Per-record view + edit
### Manual Edit Flow
1. User edits record via UI (Detail page) → `PUT /api/attendance/{id}` → marks `is_manually_edited=true`
2. Next upload of Excel → import loop checks `is_manually_edited` → skips this record (preserves manual change)
### Known Data Quirks
- `expected_in` / `expected_out` stored as strings ("11:00") for direct UI display
- `actual_in` / `actual_out` same — strings, NOT datetimes
- `Shift.get_schedule()` returns strings — caller must `datetime.strptime(start_str, "%H:%M").time()` before passing to `calculate_attendance_status`
### Files
- Backend: `/root/aars/backend/main.py` (~2100 lines), `models.py`, `database.py`, `schemas.py`, `auth.py`
- DB file: `/root/aars/data/aars.db` (SQLite, bind-mounted into container)
- Frontend: `/root/aars/frontend/src/pages/Dashboard.jsx`, `pages/attendance/List.jsx`, `pages/attendance/Detail.jsx`
- Excel data: `/root/aars/data/attendance.xlsx` (legacy), `roster.xlsx`, `accident.xlsx`
### Computed Backend Data Caveats
- **Status counts are dynamic** — computed from `attendance_records` table at query time
- **No materialized views** — query heavy if data grows beyond ~100k records
- **Roster changes affect historical data** — if you update `Shift.mon_start` for S7a, all past records with `shift_code="S7a"` are NOT auto-recalculated (run recompute manually if needed)
## 🐛 Debug Logging
Structured JSON logs written to `/root/aars/logs/` (bind-mounted into container at `/app/logs/`). Designed for production debugging — NOT compliance/audit.
### Files
| File | Contents | Retention |
|------|----------|-----------|
| `app-YYYY-MM-DD.jsonl` | General INFO+ events | 7 days |
| `error-YYYY-MM-DD.jsonl` | Server + client errors (with stack) | 30 days |
| `access-YYYY-MM-DD.jsonl` | One entry per HTTP request | 7 days |
Files auto-rotate daily via in-process `DailyJSONLHandler`. Old files deleted by `/root/aars/scripts/logs-cleanup.sh` (cron at 03:00 recommended).
### Log Schema
Every entry is single-line JSON:
```json
{
"ts": "2026-07-17T08:30:00.123Z",
"level": "info",
"service": "aars-backend",
"env": "production",
"request_id": "abc123-...",
"route": "/api/dashboard/summary",
"method": "GET",
"status": 200,
"duration_ms": 114,
"msg": "human readable summary",
"error": { "name": "...", "message": "...", "stack": "..." }
}
```
- `ts`: ISO 8601 UTC
- `level`: `debug` / `info` / `warn` / `error`
- `service`: `aars-backend` (frontend errors tagged with `"source": "client"` in `context`)
- `env`: hardcoded `production`
- `request_id`: from `X-Request-ID` header (auto-generated if missing, validated if present)
- Sensitive fields auto-redacted (see below)
### Sensitive Redaction
Backend redactor scrubs these substrings from any log payload (dicts/strings walked recursively):
- `password`, `passwd`, `pwd`, `pass`
- `token`, `access_token`, `refresh_token`, `id_token`, `jwt`, `bearer`
- `api_key`, `apikey`, `secret`, `client_secret`
- `authorization`, `cookie`, `set-cookie`, `session`
- `database_url`, `db_url`, `connection_string`
JWT regex: `eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+``[REDACTED:JWT]`
**Verification**:
```bash
grep -l password /root/aars/logs/*.jsonl # should return nothing
grep admin123 /root/aars/logs/*.jsonl # should return nothing
```
### Reproducing an Error (workflow for ITdog)
1. User reports an issue with `request_id` (visible in browser DevTools → Network → Response Headers → `X-Request-ID`)
2. SSH to VPS:
```bash
ssh root@187.127.116.15
```
3. Search by request_id across all 3 files:
```bash
grep '"request_id":"paste-id-here"' /root/aars/logs/{app,error,access}-2026-07-17.jsonl | jq .
```
4. Search by route / status / message:
```bash
grep '"route":"/api/dashboard/summary"' /root/aars/logs/error-*.jsonl | jq .
grep '"status":500' /root/aars/logs/access-*.jsonl | jq .
grep 'login failed' /root/aars/logs/app-*.jsonl | jq .
```
5. Live tail for new errors:
```bash
tail -f /root/aars/logs/error-*.jsonl | jq .
```
### Live Tailing
```bash
tail -f /root/aars/logs/app-2026-07-17.jsonl | jq . # app events
tail -f /root/aars/logs/access-2026-07-17.jsonl | jq . # HTTP requests
tail -f /root/aars/logs/error-2026-07-17.jsonl | jq . # errors
```
(Requires `jq` — `apt install -y jq` on VPS if not present.)
### npm Scripts (from frontend dir)
```bash
cd /root/aars/frontend
npm run logs:tail # tail app log
npm run logs:errors # tail error log
npm run logs:access # tail access log
npm run logs:search -- '"route":"/api/dashboard"' # search all 3 files
npm run logs:clean # manually trigger retention cleanup
```
### Triggering a Test Client Error
From browser console:
```js
import('/src/lib/logger.js').then(m => m.logger.error('manual test', { stack: 'fake stack' }))
```
Or via curl (no auth required):
```bash
curl -X POST https://excel.donton.cloud/api/client-logs \
-H "Content-Type: application/json" \
-H "X-Request-ID: my-debug-test" \
-d '{"level":"error","msg":"manual curl test","stack":"foo","page_url":"https://excel.donton.cloud/dashboard","app_version":"1.0.0"}'
```
Then verify:
```bash
grep "my-debug-test" /root/aars/logs/error-*.jsonl | jq .
```
### Manual Retention Cleanup
```bash
npm run logs:clean
# or directly
bash /root/aars/scripts/logs-cleanup.sh
```
To schedule daily, add to crontab on the host:
```cron
0 3 * * * /root/aars/scripts/logs-cleanup.sh >> /var/log/aars-cleanup.log 2>&1
```
### Architecture
**Backend** (FastAPI stdlib only, no new deps):
- `backend/logging_config.py`:
- `JSONFormatter` — formats `LogRecord` → single-line JSON
- `DailyJSONLHandler` — in-process daily file rotation (not stdlib `TimedRotatingFileHandler` which has awkward filename semantics)
- `_scrub(obj)` — recursive dict walker + JWT regex
- `ContextVar` `request_id_var` + `user_id_var` for cross-function context
- Console handler with `ContextFilter` for human-readable stdout output
- `backend/middleware.py`:
- `RequestIDMiddleware` — generate uuid4 if `X-Request-ID` missing, validate if present (`[A-Za-z0-9_-]{1,128}` regex)
- `AccessLogMiddleware` — log every request with route/method/status/duration_ms
- `unhandled_exception_handler` — catch-all 500 with stack trace
**Frontend** (vanilla JS, no extra deps):
- `frontend/src/lib/logger.js`:
- Buffer (50 entries max), flush every 10s + on `pagehide` + on visibility hidden
- `navigator.sendBeacon` (preferred) → `fetch` fallback
- Captures `console.error`, `unhandledrejection`, ErrorBoundary errors
- Redacts same keys as backend (client-side filter)
- `frontend/src/api.js`:
- Axios interceptor — generate `X-Request-ID` per request, capture from response
- `frontend/src/components/ErrorBoundary.jsx`:
- React error boundary that posts to `/api/client-logs`
- `frontend/src/main.jsx`:
- Wraps app in ErrorBoundary, installs global error handlers
**Server endpoint**:
- `POST /api/client-logs` — no auth, validates via `ClientLogEntry` Pydantic schema, writes to error log with `source: client` tag
### Excluded (by design)
- ❌ Rate limiting on `/api/client-logs` — debug use, no DDoS concern
- ❌ PII pattern scrubbing (emails, HKID) — DB has no such data
- ❌ Lint / type / unit tests — user said no testing around
- ❌ Compliance / audit logging — no requirements
- ❌ Centralized log aggregation (ELK, Datadog) — VPS-only, SSH access suffices
### Files
| Path | Purpose |
|------|---------|
| `/root/aars/backend/logging_config.py` | Formatter + handler + redactor |
| `/root/aars/backend/middleware.py` | RequestID + AccessLog + exception handler |
| `/root/aars/scripts/logs-cleanup.sh` | Retention cron (run daily) |
| `/root/aars/logs/app-*.jsonl` | App events (7d) |
| `/root/aars/logs/error-*.jsonl` | Errors + stack traces (30d) |
| `/root/aars/logs/access-*.jsonl` | HTTP access log (7d) |
| `/root/aars/frontend/src/lib/logger.js` | Client logger |
| `/root/aars/frontend/src/api.js` | Axios + X-Request-ID interceptor |
| `/root/aars/frontend/src/components/ErrorBoundary.jsx` | React error boundary |
### Known Issues
1. **`jq` not pre-installed on VPS** — `apt install -y jq` required for pretty-printed logs
2. **Crontab for `logs-cleanup.sh` not auto-installed** — manually add `0 3 * * *` cron entry on host
3. **Container healthcheck always reports unhealthy** — uses `/api/auth/me` (returns 403 without auth). Cosmetic only, doesn't affect functionality.