Shipment tracking¶
Opt-in table for tracking physical delivery of certificates. Admins upsert records; students look up the status of their own.
Enabling¶
"features": {
"shipment": {
"enabled": true,
"statuses": ["pending", "packed", "shipped", "delivered", "returned"],
"fields": ["tracking_code", "carrier", "shipped_at", "note"],
"public_fields": ["carrier"]
}
}
statuses: the allowed values for thestatuscolumn. Must be non-empty and case-insensitive-unique.fields: extra TEXT columns on theshipmentstable. Each must be a SQL identifier. Reserved names (id,round_id,sbd,status,created_at,updated_at) are rejected to avoid collisions with the fixed schema.public_fields: subset offieldsthat the public lookup endpoint is allowed to return. Default empty. Students see onlystatusandupdated_atunless you allowlist more.
Table layout¶
shipments:
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | UUID. |
round_id |
TEXT | From your rounds list. |
sbd |
TEXT | Student's registration number. |
status |
TEXT | One of features.shipment.statuses. |
created_at / updated_at |
TEXT | ISO 8601 UTC. |
…fields[] |
TEXT | Your declared extras. |
UNIQUE(round_id, sbd) |
Composite key: one row per cert per student. |
Admin API¶
POST /api/shipment/upsert (requires JWT; viewer role rejected):
{
"token": "<admin JWT>",
"sbd": "12345",
"round_id": "main",
"status": "shipped",
"updates": { "tracking_code": "VN123", "carrier": "GHN" }
}
Uses SQLite's INSERT ... ON CONFLICT DO UPDATE so two admins pressing Save on the same row simultaneously don't race into IntegrityError. Patch semantics on conflict: only caller-supplied fields overwrite; untouched columns keep their prior values.
Activity log records shipment.upsert with status + fields_touched (the list of keys, not their values, which keeps tracking codes and addresses out of the webhook stream).
Public lookup¶
POST /api/shipment/lookup:
Goes through the same CAPTCHA + rate-limit gate as student search. Response shape:
fields carries only keys in features.shipment.public_fields. id, created_at, and other internals are not exposed.
Status discipline¶
upsert_shipment validates status against the configured list before writing. A typo in the admin UI surfaces as a 400 with a listing of the allowed values.
Listing shipments¶
list_shipments(db, config, status=..., round_id=..., limit=200) returns the most recent N (capped at MAX_LIST_LIMIT = 500) sorted by updated_at DESC. The admin panel uses this for the "shipments" tab (Phase 10+).
Feature flag guards¶
All three entry points (upsert_shipment_record, lookup_shipment, build_shipment_schema) raise if features.shipment.enabled is false, so you can toggle the feature off without dropping the table.
Migration note¶
Adding a new entry to features.shipment.fields works on an existing database. SQLite's dynamic typing is forgiving enough that the CREATE TABLE IF NOT EXISTS approach doesn't alter the existing schema. If you need to add columns to a populated DB, use ALTER TABLE manually; there's no migration framework.
Draft → export → ship → import lifecycle¶
Full shipping workflow has four states: draft → exported → in_transit (via import) → delivered/failed. Admins curate drafts, hit export to produce a carrier-ready Excel + hard-lock the batch, upload to the carrier portal, and let the bulk-import pipeline close the loop when tracking data comes back.
Draft table¶
A dedicated shipment_draft table is created on first use. PK: (round_id, sbd, status), giving one active draft per student per round. Status values:
draft: admin added, editable, not yet exportedexported: included in an export batch; HARD-LOCKED (cannot edit; cancel only)cancelled: voided before or after exportpromoted: a carrier tracking code matched this draft; seeshipment_historyfor live status
Add drafts¶
# By column value
lvt-cert shipment draft add --round main --filter ship_method=CA_NHAN --token $TOK
# By result tier (matches any subject column carrying the result)
lvt-cert shipment draft add --round main --result GOLD --token $TOK
# From an Excel/CSV with SBDs in column 1
lvt-cert shipment draft add --round main --from-file ships.xlsx --token $TOK
# Combined
lvt-cert shipment draft add --round main \
--filter ship_method=CA_NHAN --result GOLD --token $TOK
At least one of --filter, --result, --from-file is required, preventing accidental full-round targeting.
List / cancel¶
lvt-cert shipment draft list --round main --status draft
lvt-cert shipment draft cancel <draft-id-1> <draft-id-2>
Export¶
Carrier profiles need an export_template block:
"viettel": {
"column_mapping": { ... }, // as before (import)
"success_keywords": [ ... ],
"export_template": {
"sbd": "Mã học viên",
"full_name": "Họ tên",
"phone": "SĐT",
"address": "Địa chỉ nhận",
"recipient": "Người nhận"
}
}
Run:
Produces an Excel file with headers from the template; address and recipient columns start empty so admins can fill in before uploading to the carrier portal.
Hard lock: after export, the participating drafts flip to exported and cannot be edited. If admin catches a mistake, draft cancel is the only way out, since carrier-side void is manual.
API endpoints (same behaviors)¶
# Add
curl -X POST -H "Content-Type: application/json" \
-d '{"token":"...","round_id":"main","filters":{"ship_method":"CA_NHAN"}}' \
https://mycerts.example/api/admin/shipments/draft
# List
curl -X POST -H "Content-Type: application/json" \
-d '{"token":"...","round_id":"main","status":"draft"}' \
https://mycerts.example/api/admin/shipments/draft/list
# Cancel
curl -X POST -H "Content-Type: application/json" \
-d '{"token":"...","ids":["<id1>","<id2>"]}' \
https://mycerts.example/api/admin/shipments/draft/cancel
# Export (downloads xlsx)
curl -X POST -H "Content-Type: application/json" \
-d '{"token":"...","round_id":"main","carrier":"viettel"}' \
https://mycerts.example/api/admin/shipments/export -o orders.xlsx
Promotion hook¶
When the carrier returns tracking data and admin runs lvt-cert import-shipments ... --commit, the import pipeline looks up each inserted row's (round_id, sbd) against shipment_draft. Any exported draft matching gets flipped to promoted with the new tracking_code stamped. This closes the state-machine loop.
Bulk import from carrier Excel/CSV¶
Carriers (Viettel Post, GHN, GHTK, …) hand operators monthly delivery exports. The lvt-cert import-shipments CLI and the POST /api/admin/shipments/import endpoint parse those files via per-carrier profiles and write rows into a dedicated shipment_history table (PK (round_id, sbd, tracking_code), with every attempt kept for audit).
Config¶
Add to cert.config.json#features.shipment:
"import": {
"default": "viettel",
"profiles": {
"viettel": {
"column_mapping": {
"tracking_code": ["Mã vận đơn", "Tracking"],
"phone": ["SĐT", "Phone"],
"status": ["Trạng thái", "Status"],
"sent_at": ["Ngày gửi"],
"address": ["Địa chỉ"],
"recipient": ["Người nhận"]
},
"success_keywords": ["GIAO THÀNH CÔNG", "PHÁT THÀNH CÔNG"],
"skip_status_prefixes": ["CH"],
"header_row": 0
},
"ghn": {
"column_mapping": {
"tracking_code": "Order Code",
"phone": "Phone",
"status": "Status"
},
"success_keywords": ["DELIVERED"]
}
}
}
Each field accepts a single string or a fallback list. If the carrier renames a header next month, update the list; no code change is needed.
CLI usage¶
# dry run: preview stats, no DB change
lvt-cert import-shipments path/to/carrier.xlsx --round main --carrier viettel
# commit after review
lvt-cert import-shipments path/to/carrier.xlsx --round main --carrier viettel --commit
# JSON output for scripting
lvt-cert import-shipments path/to/carrier.xlsx --carrier viettel --json
Dry-run is the default on purpose, so the operator eyeballs the status breakdown + match rate before writing. Re-run with --commit to persist.
API usage¶
curl -F file=@carrier.xlsx \
-F token="$ADMIN_JWT" \
-F round_id=main \
-F carrier=viettel \
-F commit=true \
https://mycerts.example/api/admin/shipments/import
- Admin-only (viewer role → 403)
- Rate-limit: 5 requests/min/IP
- Max file: 10 MB (tunable via
SHIPMENT_IMPORT_MAX_BYTESenv) - Only
.xlsx,.xlsm,.csvaccepted
How matching works¶
- Parse each row via
column_mapping; the first header name present wins - Normalize phone (strip non-digits + leading zero, VN convention)
- Dedup by
tracking_code; the earlier row wins on conflict - Query
students.phoneto resolve SBDs (one phone may map to multiple SBDs; one shipment row per match) - Rows with status starting from
skip_status_prefixesare excluded - Status case-insensitive substring match against
success_keywords→is_successflag
Audit trail¶
Each import emits one shipment.bulk_import entry in admin_activity with metadata {parsed, matched_sbds, inserted, success_count, unmatched_phones, committed}. No raw row data leaks into the log; PII stays in the SQLite shipment_history table only.
Troubleshooting¶
- Low match rate: most SBDs often ship via school bulk, not individual carrier. Expected.
- Status not detected as success: add keyword to
success_keywords. CLI's dry-runStatus breakdownsection shows all raw values. data_mapping.phone_colerror: import requires a phone column on students; set it and re-ingest.- Unknown headers:
first_matching_headercouldn't resolve. Run the file through the CLI; error lists which logical fields are unresolved + file's headers.