# D-Pro — Sổ tay vận hành

> Sắp xếp theo **triệu chứng**, không theo thành phần. Lúc có sự cố người ta biết *cái gì đang
> hỏng*, chứ chưa biết *nó nằm ở đâu*.
>
> **Trạng thái dự án / mốc M1–M2:** xem `docs/STATUS.md` (cập nhật 21/08/2026).

Mọi lệnh giả định đang ở thư mục dự án trên máy chủ (`/opt/d-pro`) và container tên
`deploy-postgres-1`, `deploy-kafka-1`, `deploy-redis-1`.

```bash
pg() { docker exec deploy-postgres-1 psql -U erp_user -d erp_mes -t -A -c "$1"; }
```

---

## 0. Kiểm tra sức khoẻ 30 giây

```bash
curl -sf http://localhost/health && echo " API OK"
docker ps --format '{{.Names}}\t{{.Status}}'

pg "SELECT 'outbox chờ đẩy:      '||COUNT(*) FROM outbox WHERE status='PENDING'
    UNION ALL SELECT 'projection lỗi:      '||COUNT(*) FROM projection_failures WHERE resolved_at IS NULL
    UNION ALL SELECT 'bút toán chưa vào sổ:'||COUNT(*) FROM ledger_posting_failures WHERE resolved_at IS NULL
    UNION ALL SELECT 'nghiệp vụ kho bỏ lỡ: '||COUNT(*) FROM inventory_hook_failures WHERE resolved_at IS NULL;"
```

Bốn con số đó là bảng điều khiển của hệ thống này:

| Con số | Bình thường | Nghĩa là gì khi tăng |
|---|---|---|
| `outbox PENDING` | ~0, nhấp nháy vài đơn vị | Kafka chết hoặc `OutboxPublisher` dừng. **Dữ liệu KHÔNG mất** — chỉ chưa sang read model |
| `projection_failures` | 0 | Một sự kiện làm projection ném lỗi 5 lần liên tiếp → vào DLQ và **bỏ qua để đi tiếp**. Read model đang thiếu bản ghi đó |
| `ledger_posting_failures` | 0 | Nghiệp vụ chưa vào được sổ cái (chưa khai định khoản / kỳ đã khoá / thiếu tài khoản) |
| `inventory_hook_failures` | 0 | Chứng từ đã ghi xong nhưng **kho chưa đổi**: phiếu ghi "đã nhận đủ" mà kho trống, lệnh ghi "làm được 30" mà 30 sản phẩm không tồn tại. Xử lý ở Tồn kho → bảng đầu trang, nút **Thử lại** sau khi khai mặt hàng |

---

## 1. Trang không truy cập được

```bash
docker ps -a --format '{{.Names}}\t{{.Status}}' | grep -E 'nginx|api'
docker logs --tail=50 deploy-nginx-1
docker logs --tail=50 deploy-api-1
```

Thứ tự nghi ngờ: **nginx không khởi động** (sai cú pháp config) → **api không healthy**
(nginx `depends_on: service_healthy` nên nó sẽ không lên) → **chứng chỉ hết hạn**.

```bash
docker exec deploy-nginx-1 nginx -t          # kiểm cú pháp config
echo | openssl s_client -connect localhost:443 2>/dev/null | openssl x509 -noout -dates
```

> Cấu hình nginx **phải được `nginx -t` trước khi triển khai**. Đã từng có lần cả file không
> khởi động nổi vì một `proxy_pass` sai trong named location, và lỗi đó nằm im nhiều tháng
> vì chưa ai chạy thử.

---

## 2. Màn hình hiện dữ liệu cũ / bản ghi vừa tạo không thấy đâu

Đây là triệu chứng **read model tụt lại**, không phải mất dữ liệu. Dữ liệu nằm an toàn trong
Event Store; chỉ phần chiếu sang bảng đọc là chậm hoặc kẹt.

```bash
pg "SELECT COUNT(*) FROM outbox WHERE status='PENDING';"      # >0 và không giảm = kẹt
# Lưu ý: image này đặt tên binary KHÔNG có đuôi .sh
docker exec deploy-kafka-1 kafka-consumer-groups --bootstrap-server localhost:9092 \
  --describe --all-groups 2>/dev/null | awk 'NR>1 && $6+0>0 {print $1, $2, "lag="$6}'
```

**Group có lag nhưng cột CONSUMER-ID là `-`** nghĩa là *không có gì đang đọc* group đó. Hai khả
năng: projection đã chết, hoặc đó là **group mồ côi** — code sinh ra nó đã bị xoá nhưng group
vẫn nằm lại trong Kafka. Group mồ côi làm mọi lần kiểm lag báo động giả, và một hệ thống báo
động giả liên tục sẽ bị người vận hành bỏ qua hết. Tìm và dọn:

```bash
for g in $(docker exec deploy-kafka-1 kafka-consumer-groups --bootstrap-server localhost:9092 --list 2>/dev/null); do
  grep -rqs "\"$g\"" src/Infrastructure/ || echo "MỒ CÔI: $g"
done
docker exec deploy-kafka-1 kafka-consumer-groups --bootstrap-server localhost:9092 \
  --delete --group <tên-group>
```

> Đã gặp thật: `ledger-autopost-sales` còn lag 17 dù `SalesAutoPostingConsumer` đã bị bỏ từ B3,
> khi điểm ghi nhận doanh thu chuyển từ giao hàng sang xuất hoá đơn. Đã dọn.

- **`outbox PENDING` tăng dần** → Kafka chết hoặc không kết nối được. Xem mục 3.
- **outbox trống nhưng consumer lag cao** → projection chạy chậm hoặc đã chết. Khởi động lại API
  (projection chạy trong tiến trình API khi `Workers:RunMessagingInProcess=true`).
- **Cả hai đều sạch mà vẫn thiếu bản ghi** → xem `projection_failures`, mục 4.

Độ trễ bình thường: **trung vị ~320ms, p95 ~460ms** (đo bằng
`LT_USER=… LT_PASSWORD=… make loadtest` — script **cần tài khoản có sẵn**, không tự tạo được nữa).
Tải đồng thời đã đo: **40 client × 2 vòng/giây = 318 req/s, 100% thành công, 0 lỗi 500**
(`make loadtest-concurrent`). Vượt vài giây
là bất thường.

---

## 3. Kafka chết

**Không cần hoảng.** Kiến trúc này chịu được: sự kiện ghi vào bảng `outbox` trong *cùng
transaction* với dữ liệu nghiệp vụ, nên nghiệp vụ vẫn ghi được bình thường; chỉ read model
đứng yên cho tới khi Kafka trở lại.

Điều này **đã được diễn tập thật** (`make chaos-kafka`): tắt Kafka, ghi 5 lệnh sản xuất, API
nhận đủ 5, outbox `0 → 5`, bật lại Kafka thì outbox `5 → 0` và read model bắt kịp đủ 5.

```bash
docker start deploy-kafka-1
watch -n3 'docker exec deploy-postgres-1 psql -U erp_user -d erp_mes -t -A \
  -c "SELECT COUNT(*) FROM outbox WHERE status='"'"'PENDING'"'"';"'
```

Con số phải giảm dần về 0. Nếu **không giảm** sau vài phút: xem log API tìm lỗi kết nối Kafka.

---

## 4. Có sự kiện không sang được read model (`projection_failures`)

Một sự kiện làm projection ném lỗi 5 lần liên tiếp sẽ bị đẩy vào DLQ và **bỏ qua để dòng sự
kiện đi tiếp** — đây là lựa chọn có chủ đích: một sự kiện hỏng không được phép chặn đứng mọi
nghiệp vụ phía sau. Nhưng nó nghĩa là **read model đang thiếu đúng bản ghi đó**.

```bash
pg "SELECT group_id, topic, event_type, left(error,120), failed_at
    FROM projection_failures WHERE resolved_at IS NULL ORDER BY failed_at DESC LIMIT 20;"
```

Xử lý: sửa nguyên nhân (thường là schema lệch hoặc dữ liệu ngoài dự kiến), rồi **tua consumer
group về offset của sự kiện đó** để nó chạy lại:

```bash
docker exec deploy-kafka-1 kafka-consumer-groups --bootstrap-server localhost:9092 \
  --group <group_id> --topic <topic> --reset-offsets --to-offset <kafka_offset> --execute
docker restart deploy-api-1
pg "UPDATE projection_failures SET resolved_at=NOW() WHERE id='<id>';"
```

> Projection được viết để **chạy lại an toàn** (`ON CONFLICT DO NOTHING`, gán giá trị tuyệt đối
> thay vì cộng dồn). Tua lại một đoạn offset không tạo bản ghi trùng.

---

## 5. Nghiệp vụ không lên sổ cái (`ledger_posting_failures`)

Bút toán tự động **không bao giờ thất bại trong im lặng** — mọi trường hợp không ghi được đều
để lại một dòng ở đây.

```bash
pg "SELECT rule_code, error_code, amount, description, created_at
    FROM ledger_posting_failures WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 20;"
```

| `error_code` | Nguyên nhân | Cách xử lý |
|---|---|---|
| `MAPPING_MISSING` | Chưa khai định khoản cho quy tắc đó | Thêm dòng vào `ledger_account_mappings` |
| `ACCOUNT_NOT_FOUND` | Tài khoản chưa có trong hệ thống tài khoản | Mở tài khoản (`/api/v1/ledger/accounts`) |
| `PERIOD_CLOSED` | Kỳ kế toán đã khoá | Mở lại kỳ, hoặc chấp nhận ghi vào kỳ hiện tại |
| `ZERO_AMOUNT` | Nhập kho không khai đơn giá | Sửa phiếu nhập rồi retry |
| `INVALID_SOURCE` | Nguồn bút toán chưa có trong `JournalSource` | Lỗi lập trình — báo dev |

Sau khi sửa nguyên nhân:

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
  http://localhost/api/v1/ledger/auto-posting/failures/<id>/retry
```

Retry lần hai trên dòng đã xử lý trả `ALREADY_RESOLVED` — an toàn, không ghi trùng.

---

## 5b. Redis chết / Postgres chết

**Ba endpoint thăm dò, ba mục đích khác nhau** — đừng dùng lẫn:

| Endpoint | Kiểm gì | Dùng cho | Redis chết | Postgres chết |
|---|---|---|---|---|
| `/health/live` | Chỉ tiến trình còn sống | Healthcheck container | 200 | **200** |
| `/health/ready` | Phụ thuộc THIẾT YẾU (Postgres) | Load balancer | 200 | 503 |
| `/health` | Tất cả, cho người đọc | Màn hình vận hành | 200 `Degraded` | 503 |

**Redis chết → không phải sự cố.** Cache đã bọc try/catch cả get lẫn set; đã kiểm thật với Redis
tắt hẳn: **mọi endpoint nghiệp vụ vẫn trả 200**, kể cả `oee/live` và `reports/dashboard` là hai
chỗ dùng cache. Chỉ chậm hơn vì mất cache.

```bash
docker start deploy-redis-1     # xong, không cần làm gì thêm
```

> Trước 12/08/2026 Redis được khai là phụ thuộc thiết yếu, nên Redis chết làm `/health` trả 503 →
> container `api` bị đánh dấu unhealthy → nginx (`depends_on: service_healthy`) không lên.
> **Một cache chết kéo sập cả ERP** dù ứng dụng hoàn toàn phục vụ được. Nay Redis báo `Degraded`.

**Postgres chết → nghiệp vụ dừng hẳn**, không có cách nào khác: đó là nguồn sự thật.

```bash
docker start deploy-postgres-1
curl -s localhost:5080/health/ready   # đợi 200
```

`/health/live` **cố ý vẫn trả 200** khi Postgres chết: khởi động lại tiến trình API không mang
Postgres về, chỉ làm phục hồi chậm thêm và mất hết kết nối đang chờ.

**Đã diễn tập thật** (`make chaos-postgres`): tắt Postgres → live vẫn 200 · ready ≠ 200 · lệnh
ghi không trả 2xx giả · bật lại → ready về 200 và ghi được **không cần restart API**.
(Tuỳ chọn: kèm `LT_USER`/`LT_PASSWORD` để kiểm bước ghi; không có thì chỉ kiểm health.)

Áp lực đĩa (không cố tình làm đầy máy): `make disk-check` — cảnh báo nếu `/` ≥ 85%, liệt kê
bảng lớn + trạng thái outbox. Thao tác dọn: mục 8 bên dưới.

---

## 5c. Người dùng báo "lưu bị lỗi, thử lại thì được"

Đó là **tranh chấp ghi đồng thời**: hai người cùng thao tác trên một bản ghi. API trả
**409 `CONCURRENCY_CONFLICT`** — đúng như thiết kế, không phải sự cố. Dữ liệu vẫn toàn vẹn:
ràng buộc unique trên `(aggregate_type, aggregate_id, sequence_number)` chặn ở mức database.

Người dùng chỉ cần tải lại rồi thao tác lại. Nếu **xảy ra thường xuyên trên cùng một bản ghi**,
đó là dấu hiệu quy trình có vấn đề — hai người đang cùng phụ trách một việc.

```bash
# Đếm xung đột gần đây trong log
docker logs deploy-api-1 --since 1h 2>&1 | grep -c CONCURRENCY_CONFLICT
```

> Trước 12/08/2026 tình huống này trả **500 kèm nguyên văn lỗi Postgres**. Nếu còn thấy 500 với
> nội dung `23505` thì bản đang chạy là bản cũ.

---


### ⚠️ KHÔNG phải mục nào trong hàng đợi cũng nên phát lại

Hướng dẫn "sửa nguyên nhân rồi phát lại" **sai trong ba tình huống** — cả ba đều gặp thật
13/08/2026 khi rà soát:

| Tình huống | Dấu hiệu | Xử lý đúng |
|---|---|---|
| **Nghiệp vụ đã ghi sổ bằng đường khác** | Có bút toán cùng `reference_id` đang `Posted` | Chỉ đánh dấu đã xử lý. Retry nay tự nhận ra và làm điều này |
| **Bản trùng bị chặn đúng** | Ràng buộc `ux_journal_auto_reference` | **Không** phát lại — phát lại là ghi trùng lên sổ |
| **Quy tắc định khoản đã ngừng dùng** | `MAPPING_MISSING` mà `is_active=false` có ghi lý do | **Không** khai lại mapping. Ví dụ `LABOR_BOOKING` ngừng từ F4 vì bảng lương là nguồn duy nhất — ghi sổ sẽ tính đúp lương |

**Trước khi phát lại, luôn hỏi: nghiệp vụ này đã có bút toán chưa?**

```bash
pg "SELECT f.reference_id, f.error_code, j.entry_number, j.status
    FROM ledger_posting_failures f
    LEFT JOIN journal_entries j
      ON j.reference_type=f.reference_type AND j.reference_id=f.reference_id
     AND j.tenant_id=f.tenant_id
    WHERE f.resolved_at IS NULL;"
```

Có `entry_number` ở cột phải nghĩa là **đã ghi rồi** — chỉ đánh dấu xử lý, đừng ghi lại.

### Cuối năm phải mở kỳ của năm sau TRƯỚC

Trả lương tháng 12 vào đầu tháng 1 là thông lệ. Chưa mở kỳ năm sau thì mọi bút toán sang năm
rơi vào hàng đợi với `PERIOD_NOT_FOUND` — đã gặp thật với một phiếu chi lương 96,5 triệu.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost/api/v1/ledger/periods/year/2027
```

---

## 6. Sao lưu và khôi phục

Chi tiết ở [`deploy/backup/README.md`](../deploy/backup/README.md).

```bash
make backup          # dump logic (+ mã hoá nếu có khoá) (+ đẩy nếu có BACKUP_REMOTE)
make restore-drill   # khôi phục dump vào DB tạm + kiểm bất biến nghiệp vụ
make basebackup      # sao lưu vật lý (nền PITR) — cần archive_mode=on
make pitr-drill      # diễn tập Point-in-Time (marker A còn, B không)
```

**Đẩy ra ngoài:** `BACKUP_ENCRYPTION_KEY_FILE=… BACKUP_REMOTE=user@host:/path make backup` —
bắt buộc mã hoá; sau `rsync` đối chiếu sha256 ở đích. Chi tiết: `deploy/backup/README.md`.

**PITR:** WAL archive vào `deploy/backup/wal_archive/` (compose đã bật `archive_mode`). Base
backup định kỳ + `make pitr-drill` hàng tuần cùng lúc với restore-drill.
**Khôi phục thật:** đọc kỹ mục cuối của `deploy/backup/README.md`. Điểm quan trọng nhất —
**đổi tên database hỏng thay vì xoá nó**. Xoá rồi mới phát hiện bản sao lưu cũng có vấn đề là
tình huống không có đường ra.

Diễn tập khôi phục nên chạy **hàng tuần**, không phải hàng năm: thứ hỏng thường là schema đã
đổi mà quy trình khôi phục chưa theo kịp, và schema thì đổi liên tục.

---

## 6b. Chạy thử song song ở một xưởng (M1)

Điều kiện kỹ thuật trong repo đã đủ. Việc còn lại là **một xưởng** nhập song song vào D-Pro
và đối chiếu cuối tháng với sổ hiện tại. Checklist dưới đây để không bỏ bước — thiếu một bước
thường làm lệch số mà không biết vì sao.

> **Cho kế toán / thủ kho / điều độ** (không phải IT): xem `docs/USER_GUIDE.md` — màn hình,
> việc hàng ngày, và các lỗi hay mắc khi song song.

### Trước ngày 1

| # | Việc | Xong khi |
|---|---|---|
| 1 | Sao lưu hệ thống cũ + `make backup` có `BACKUP_REMOTE` + `make restore-drill` ĐẠT | Có bản dump ngoài máy và đã khôi phục thử |
| 2 | Khai hồ sơ doanh nghiệp (`/master-data` → Doanh nghiệp) | Có tên · địa chỉ · MST đúng |
| 3 | Mở kỳ kế toán đầu vận hành | Kỳ đang mở trên `/ledger` |
| 4 | Nạp số dư đầu kỳ: sổ cái → tồn kho → AR → AP (`/opening-balance`) | Bốn bước đều xanh; nạp lại bị chặn |
| 5 | Đối chiếu: tab **Đối chiếu** trên `/opening-balance` (tồn = 152+155 · AR = 131 · AP = 331) | Lệch = dừng, sửa trước khi nhập nghiệp vụ |
| 6 | Tài khoản ngân hàng (mỗi NH một TK 112x) + nhập sổ phụ gần nhất | Tab Ngân hàng trên `/ledger` đối chiếu được |
| 7 | Tài khoản vận hành xưởng (Production / Inventory / …) — **không** dùng chung Admin | Đăng nhập được; 403 đúng chỗ không thuộc quyền |
| 8 | (Tuỳ chọn) Ma trận duyệt lệnh SX — chỉ bật nếu người duyệt là tài khoản **có thật** | Tắt = hành vi như trước; bật = `Start` bị chặn tới khi duyệt |

### Trong tháng

- Nhập **mọi** nghiệp vụ song song (mua · SX · bán · thu/chi) — không chỉ “thử vài phiếu”.
- Cuối mỗi ngày (hoặc cuối tuần): nhìn ba bảng theo dõi trên RUNBOOK mục 1
  (`outbox` · `projection_failures` · `ledger_posting_failures`) và hàng đợi hook kho trên
  lưới tồn kho. Có dòng mới → xử lý trong ngày, đừng để dồn cuối tháng.
- Chạy lại tab **Đối chiếu** trên `/opening-balance` — lệch chi tiết ↔ sổ cái không làm lệch
  bảng cân đối nên phải chủ động nhìn.
- Không chạy E2b/E1 “thử cho vui” trên kỳ đang song song — kết chuyển là **một lần/kỳ**.
  Dùng xem trước (`previewOnly` trong thân request).

### Cuối tháng — đối chiếu với sổ cũ

| Hạng mục | Trên D-Pro | So với sổ cũ |
|---|---|---|
| Tồn kho | `/inventory` + giá trị | Từng mã / từng kho |
| Công nợ phải thu / trả | tuổi nợ AR/AP | Từng khách / NCC |
| Giá thành / giá vốn | phiếu giá thành · 632 | Lô hoặc lệnh đã chốt |
| Tiền | đối chiếu sổ phụ NH | Sao kê ngân hàng |
| Kết quả kỳ | B02-DN · B01-DN (xem trước E1) | Báo cáo kết quả / cân đối của họ |

Lệch → ghi rõ **phiếu nào** (số chứng từ D-Pro + số chứng từ cũ), không chỉ ghi tổng.
Tổng lệch mà không chỉ được phiếu là không sửa được.

### Cố ý chưa đòi ở lần chạy song song đầu

- Hoá đơn điện tử (M2.1) — chưa cần phát hành HĐĐT khi vẫn dùng hệ thống cũ song song
- Nộp tờ khai XML lên cơ quan thuế — xuất file để tập; nộp thật khi đã tin số
- Máy quét phần cứng — có thể nhập tay / wedge tạm

---

## 7. Chứng chỉ TLS

```bash
echo | openssl s_client -connect <domain>:443 2>/dev/null | openssl x509 -noout -dates
docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /opt/d-pro/certbot:/var/www/certbot \
  certbot/certbot renew --webroot --webroot-path=/var/www/certbot --dry-run
```

Nếu `--dry-run` hỏng, sửa **ngay** — đừng đợi tới ngày thứ 90. Nguyên nhân phổ biến nhất:
đường `/.well-known/acme-challenge/` bị chuyển hướng sang HTTPS. Nó **phải ở lại HTTP**.

---

## 8. Đầy đĩa

```bash
df -h
docker system df
pg "SELECT pg_size_pretty(pg_database_size('erp_mes'));"
pg "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_stat_user_tables
    ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;"
```

Hai chỗ phình nhanh nhất: **`events`** (không bao giờ xoá — đó là nguồn sự thật) và **`outbox`**
(dòng đã publish có thể dọn). Log Docker cũng phình nếu chưa giới hạn — prod/staging đã đặt
`max-size: 50m, max-file: 5`.

```bash
docker image prune -f
pg "DELETE FROM outbox WHERE status='PUBLISHED' AND created_at < NOW() - INTERVAL '30 days';"
```

> **Không bao giờ xoá dòng trong `events`, `gl_postings`, hay `journal_entry_lines`.** Đó là sổ
> sách. Bảng `gl_postings` được thiết kế chỉ-thêm có chủ đích.

---

## 9. Những gì hệ thống này CHƯA lo

Nói ra để không ai giả định nhầm là đã có:

- **WAL archive chứa dữ liệu dạng rõ** — để trên đĩa tin cậy / volume mã hoá máy chủ; không
  đẩy WAL thô ra chỗ không tin. Dump mã hoá + `BACKUP_REMOTE` lo nhánh mất cả máy. PITR
  (`make basebackup` / `make pitr-drill`) lo cửa sổ “oops vài giờ gần đây”.
- **Kafka một broker.** Broker chết thì read model đứng cho tới khi nó sống lại (nghiệp vụ vẫn
  chạy — xem mục 3), nhưng không có dự phòng.
- **Rate limit API 3000 req/phút mỗi người dùng** (nâng từ 300 ngày 21/08/2026 sau khi đo
  `MODE=shared`). Nhiều máy tính bảng **chung một tài khoản** vẫn chia một hạn mức — tải cực dày
  (vd. 20 máy × 2 vòng/s) vẫn có thể 429; `AuthPermitLimit=20` theo IP không đổi.
- **Đã đo tải đồng thời** (`make loadtest-concurrent`, cả `MODE=separate` và `MODE=shared`).
