release: cut v2.1
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-06-13 14:49:58 +08:00
parent eb4f3e1e96
commit 3f46811e49
144 changed files with 22290 additions and 1057 deletions

View File

@@ -189,6 +189,48 @@ order.to_dict(decrypt_sensitive=False)
`masking` 模块是纯字符串工具,与 `crypto` 正交可在任意层API 序列化、Jinja2
模板过滤器、日志格式化、CSV 导出器)独立复用。
## T4.3 订单管理 CLI
入口:`scripts/gaokao-order-manager`
```bash
# 创建订单
python3 scripts/gaokao-order-manager create \
--source web --service-version standard --amount-cents 9900 \
--customer-name 张三 --customer-phone 13800001234 \
--candidate-name 李同学 --candidate-province 湖南 --candidate-score 578
# 订单列表 / 详情
python3 scripts/gaokao-order-manager list --status pending
python3 scripts/gaokao-order-manager show GKO-20260612-ABCD
# 更新业务字段(禁止直接改 status
python3 scripts/gaokao-order-manager update GKO-20260612-ABCD \
--assigned-consultant consultant-a --note 已分配顾问 --tag VIP
# 支付 / 交付 / 升级
python3 scripts/gaokao-order-manager pay GKO-20260612-ABCD --reason wechat_pay
python3 scripts/gaokao-order-manager deliver GKO-20260612-ABCD --reason report_ready
python3 scripts/gaokao-order-manager upgrade GKO-20260612-ABCD \
--service-version standard --target-amount-cents 9900 --reason upgrade_to_standard
# 统计 / 最小导出
python3 scripts/gaokao-order-manager stats
python3 scripts/gaokao-order-manager export \
--output /tmp/orders-report.csv --status pending --source school
```
子命令:
- `create`:新建订单,默认输出 JSON敏感字段默认遮罩
- `list` / `show`:查询订单;`show` 同时返回 `history`
- `update`:只允许改业务字段,拒绝空更新与直接改 `status`
- `pay`:推进 `pending -> paid`
- `deliver`:从 `paid` 自动推进 `serving -> delivered`;若已在 `serving` 则只做最后一步
- `upgrade`:按目标总价创建补差价升级单,关联 `upgrade_from`,并给原单补 `upgraded` 标记
- `stats`:返回 `total_orders``by_status``by_source``by_service_version`
- `export`:导出最小 CSV 报表,字段固定为 `订单号/渠道/金额/状态/创建时间`
## 下游衔接
- **T4.3 CLI**: `gaokao-order-manager` 直接 import `OrdersDAO` + `Order`
@@ -198,6 +240,8 @@ order.to_dict(decrypt_sensitive=False)
## 版本
v1.4 — 2026-06-12 — T4.5 最小导出CLI export 子命令 + CSV 报表字段 `订单号/渠道/金额/状态/创建时间`
v1.3 — 2026-06-12 — T4.4 升级订单流程upgrade 子命令 + upgrade_order + 补差价校验)
v1.2 — 2026-06-12 — T4.2 DAO 层落地51 用例ruff 0 warningdata/ 386 用例全绿)
v1.1 — 2026-06-12 — T11.2 展示脱敏mask 策略 + masking.py
v1.0 — 2026-06-12 — T4.1 实施

455
data/orders/cli.py Normal file
View File

@@ -0,0 +1,455 @@
"""gaokao-order-manager CLI implementation (T4.3/T4.5)."""
from __future__ import annotations
import argparse
import csv
import json
import sys
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
from .dao import DuplicateOrder, OrderNotFound, OrdersDAO
from .models import Order, generate_order_id
from .state_machine import InvalidStateTransition
DEFAULT_DB_PATH = Path("data/orders.db")
_EXPORT_HEADERS = ("订单号", "渠道", "金额", "状态", "创建时间")
def _emit(payload: dict[str, Any], *, human: bool) -> None:
if human:
for key, value in payload.items():
if isinstance(value, (dict, list)):
print(f"{key}: {json.dumps(value, ensure_ascii=False, indent=2)}")
else:
print(f"{key}: {value}")
return
print(json.dumps(payload, ensure_ascii=False, indent=2))
def _error(message: str, code: int = 1) -> int:
print(message, file=sys.stderr)
return code
def _serialize_order(order: Order) -> dict[str, Any]:
return order.to_dict()
def _serialize_history(dao: OrdersDAO, order_id: str) -> list[dict[str, Any]]:
return [asdict(item) for item in dao.get_status_history(order_id)]
def _build_order_from_args(args: argparse.Namespace) -> Order:
return Order(
id=generate_order_id(),
source=args.source,
external_id=args.external_id,
service_version=args.service_version,
amount_cents=args.amount_cents,
status="pending",
customer_name=args.customer_name,
customer_phone=args.customer_phone,
customer_wechat=args.customer_wechat,
candidate_name=args.candidate_name,
candidate_id_card=args.candidate_id_card,
candidate_province=args.candidate_province,
candidate_score=args.candidate_score,
candidate_rank=args.candidate_rank,
candidate_subjects=args.candidate_subjects or [],
candidate_interests=args.candidate_interests,
candidate_strong_subjects=args.candidate_strong_subjects,
candidate_weak_subjects=args.candidate_weak_subjects,
candidate_family=args.candidate_family,
assigned_consultant=args.assigned_consultant,
notes=args.note,
tags=args.tags or [],
)
def cmd_create(args: argparse.Namespace) -> int:
order = _build_order_from_args(args)
with OrdersDAO.connect(args.db) as dao:
try:
created = dao.create(order, actor=args.actor, reason=args.reason)
except DuplicateOrder as exc:
return _error(f"创建失败: {exc}")
payload = {
"action": "created",
"order": _serialize_order(created),
"history": _serialize_history(dao, created.id),
}
_emit(payload, human=args.human)
return 0
def cmd_list(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
try:
orders = dao.list(
status=args.status,
source=args.source,
limit=args.limit,
offset=args.offset,
)
except ValueError as exc:
return _error(f"查询失败: {exc}", code=2)
payload = {
"count": len(orders),
"orders": [_serialize_order(order) for order in orders],
}
_emit(payload, human=args.human)
return 0
def cmd_show(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
try:
order = dao.get(args.order_id)
except OrderNotFound as exc:
return _error(str(exc))
payload = {
"order": _serialize_order(order),
"history": _serialize_history(dao, order.id),
}
_emit(payload, human=args.human)
return 0
def _collect_updates(args: argparse.Namespace) -> dict[str, Any]:
updates: dict[str, Any] = {}
field_names = (
"external_id",
"service_version",
"amount_cents",
"customer_name",
"customer_wechat",
"candidate_name",
"candidate_province",
"candidate_score",
"candidate_rank",
"candidate_interests",
"candidate_strong_subjects",
"candidate_weak_subjects",
"candidate_family",
"assigned_consultant",
"plan_file",
"audit_report",
"pdf_path",
)
for name in field_names:
value = getattr(args, name)
if value is not None:
updates[name] = value
if args.note is not None:
updates["notes"] = args.note
if args.tags is not None:
updates["tags"] = args.tags
if args.candidate_subjects is not None:
updates["candidate_subjects"] = args.candidate_subjects
return updates
def cmd_update(args: argparse.Namespace) -> int:
updates = _collect_updates(args)
if not updates:
return _error("至少指定一个可更新字段", code=2)
with OrdersDAO.connect(args.db) as dao:
try:
order = dao.update(
args.order_id, updates, actor=args.actor, reason=args.reason
)
except OrderNotFound as exc:
return _error(str(exc))
except ValueError as exc:
return _error(f"更新失败: {exc}", code=2)
payload = {
"action": "updated",
"order": _serialize_order(order),
}
_emit(payload, human=args.human)
return 0
def _transition(
dao: OrdersDAO,
order_id: str,
to_status: str,
*,
actor: str,
reason: Optional[str],
) -> Order:
return dao.transition_status(order_id, to_status, actor=actor, reason=reason)
def cmd_pay(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
try:
order = _transition(
dao,
args.order_id,
"paid",
actor=args.actor,
reason=args.reason or "manual_pay",
)
except (OrderNotFound, InvalidStateTransition) as exc:
return _error(str(exc))
payload = {
"action": "paid",
"order": _serialize_order(order),
"history": _serialize_history(dao, order.id),
}
_emit(payload, human=args.human)
return 0
def cmd_deliver(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
try:
current = dao.get(args.order_id)
except OrderNotFound as exc:
return _error(str(exc))
try:
if current.status == "paid":
_transition(
dao,
args.order_id,
"serving",
actor=args.actor,
reason=args.reason or "deliver:start_service",
)
order = _transition(
dao,
args.order_id,
"delivered",
actor=args.actor,
reason=args.reason or "deliver:done",
)
elif current.status == "serving":
order = _transition(
dao,
args.order_id,
"delivered",
actor=args.actor,
reason=args.reason or "deliver:done",
)
else:
return _error(
f"当前状态不允许 deliver: {current.status};请先完成 pay 或人工推进到 serving"
)
except InvalidStateTransition as exc:
return _error(str(exc))
payload = {
"action": "delivered",
"order": _serialize_order(order),
"history": _serialize_history(dao, order.id),
}
_emit(payload, human=args.human)
return 0
def cmd_upgrade(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
try:
order = dao.upgrade_order(
args.order_id,
target_service_version=args.service_version,
target_amount_cents=args.target_amount_cents,
actor=args.actor,
reason=args.reason,
)
source_order = dao.get(args.order_id)
except (OrderNotFound, ValueError) as exc:
return _error(str(exc), code=2)
payload = {
"action": "upgraded",
"order": _serialize_order(order),
"source_order": _serialize_order(source_order),
"history": _serialize_history(dao, order.id),
}
_emit(payload, human=args.human)
return 0
def cmd_stats(args: argparse.Namespace) -> int:
with OrdersDAO.connect(args.db) as dao:
by_status = dao.stats_by_status()
by_source_rows = dao.conn.execute(
"SELECT source, COUNT(*) AS n FROM orders GROUP BY source ORDER BY source ASC"
).fetchall()
by_service_rows = dao.conn.execute(
"SELECT service_version, COUNT(*) AS n FROM orders GROUP BY service_version ORDER BY service_version ASC"
).fetchall()
payload = {
"total_orders": dao.count(),
"by_status": by_status,
"by_source": {row[0]: int(row[1]) for row in by_source_rows},
"by_service_version": {row[0]: int(row[1]) for row in by_service_rows},
}
_emit(payload, human=args.human)
return 0
def _format_amount_cents(amount_cents: int) -> str:
return f"{amount_cents / 100:.2f}"
def _export_row(order: Order) -> dict[str, str]:
return {
"订单号": order.id,
"渠道": order.source,
"金额": _format_amount_cents(order.amount_cents),
"状态": order.status,
"创建时间": order.created_at or "",
}
def cmd_export(args: argparse.Namespace) -> int:
output_path = Path(args.output)
with OrdersDAO.connect(args.db) as dao:
try:
orders = dao.list(
status=args.status,
source=args.source,
limit=args.limit,
offset=0,
)
except ValueError as exc:
return _error(f"导出失败: {exc}", code=2)
rows = [_export_row(order) for order in orders]
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8-sig", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(_EXPORT_HEADERS)
for row in rows:
writer.writerow([row[header] for header in _EXPORT_HEADERS])
payload = {
"action": "exported",
"format": "csv",
"output": str(output_path),
"rows": len(rows),
}
_emit(payload, human=args.human)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="gaokao-order-manager",
description="高考志愿订单管理 CLI (T4.3)",
)
parser.add_argument("--db", default=str(DEFAULT_DB_PATH), help="SQLite 数据库路径")
parser.add_argument("--human", action="store_true", help="输出人类可读文本")
parser.add_argument("--actor", default="order_cli", help="审计 actor")
subparsers = parser.add_subparsers(dest="command", required=True)
create = subparsers.add_parser("create", help="创建订单")
create.add_argument("--source", required=True)
create.add_argument("--external-id")
create.add_argument("--service-version", required=True)
create.add_argument("--amount-cents", type=int, required=True)
create.add_argument("--customer-name")
create.add_argument("--customer-phone")
create.add_argument("--customer-wechat")
create.add_argument("--candidate-name")
create.add_argument("--candidate-id-card")
create.add_argument("--candidate-province")
create.add_argument("--candidate-score", type=int)
create.add_argument("--candidate-rank", type=int)
create.add_argument(
"--candidate-subject", dest="candidate_subjects", action="append"
)
create.add_argument("--candidate-interests")
create.add_argument("--candidate-strong-subjects")
create.add_argument("--candidate-weak-subjects")
create.add_argument("--candidate-family")
create.add_argument("--assigned-consultant")
create.add_argument("--note")
create.add_argument("--tag", dest="tags", action="append")
create.add_argument("--reason")
create.set_defaults(func=cmd_create)
list_parser = subparsers.add_parser("list", help="分页列出订单")
list_parser.add_argument("--status")
list_parser.add_argument("--source")
list_parser.add_argument("--limit", type=int, default=50)
list_parser.add_argument("--offset", type=int, default=0)
list_parser.set_defaults(func=cmd_list)
show = subparsers.add_parser("show", help="查看订单详情")
show.add_argument("order_id")
show.set_defaults(func=cmd_show)
update = subparsers.add_parser("update", help="更新订单业务字段")
update.add_argument("order_id")
update.add_argument("--external-id")
update.add_argument("--service-version")
update.add_argument("--amount-cents", type=int)
update.add_argument("--customer-name")
update.add_argument("--customer-wechat")
update.add_argument("--candidate-name")
update.add_argument("--candidate-province")
update.add_argument("--candidate-score", type=int)
update.add_argument("--candidate-rank", type=int)
update.add_argument(
"--candidate-subject", dest="candidate_subjects", action="append"
)
update.add_argument("--candidate-interests")
update.add_argument("--candidate-strong-subjects")
update.add_argument("--candidate-weak-subjects")
update.add_argument("--candidate-family")
update.add_argument("--assigned-consultant")
update.add_argument("--plan-file")
update.add_argument("--audit-report")
update.add_argument("--pdf-path")
update.add_argument("--note")
update.add_argument("--tag", dest="tags", action="append")
update.add_argument("--reason")
update.set_defaults(func=cmd_update)
pay = subparsers.add_parser("pay", help="标记已支付")
pay.add_argument("order_id")
pay.add_argument("--reason")
pay.set_defaults(func=cmd_pay)
deliver = subparsers.add_parser("deliver", help="推进到已交付")
deliver.add_argument("order_id")
deliver.add_argument("--reason")
deliver.set_defaults(func=cmd_deliver)
upgrade = subparsers.add_parser("upgrade", help="创建补差价升级订单")
upgrade.add_argument("order_id")
upgrade.add_argument("--service-version", required=True)
upgrade.add_argument("--target-amount-cents", type=int, required=True)
upgrade.add_argument("--reason")
upgrade.set_defaults(func=cmd_upgrade)
stats = subparsers.add_parser("stats", help="查看订单统计")
stats.set_defaults(func=cmd_stats)
export = subparsers.add_parser("export", help="导出最小订单报表 CSV")
export.add_argument("--output", required=True, help="输出 CSV 文件路径")
export.add_argument("--status")
export.add_argument("--source")
export.add_argument("--limit", type=int, default=1000)
export.set_defaults(func=cmd_export)
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -43,7 +43,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator, List, Optional, Union
from .models import Order, utc_now_iso
from .models import Order, generate_order_id, utc_now_iso
from .schema import apply_schema
from .state_machine import (
InvalidStateTransition,
@@ -410,6 +410,89 @@ class OrdersDAO:
).fetchone()
return self._row_to_order(row)
def upgrade_order(
self,
order_id: str,
*,
target_service_version: str,
target_amount_cents: int,
actor: Optional[str] = None,
reason: Optional[str] = None,
) -> Order:
"""基于已有订单创建补差价升级单,并回写原单升级标记。"""
original = self.get(order_id)
if target_amount_cents <= original.amount_cents:
raise ValueError(
"target_amount_cents 必须高于原订单金额,才能生成补差价升级单"
)
with self._row_factory_ctx():
existing = self._conn.execute(
f"SELECT {self._select_columns()} FROM orders WHERE upgrade_from=? LIMIT 1",
(order_id,),
).fetchone()
if existing is not None:
existing_order = self._row_to_order(existing)
raise ValueError(f"原订单已存在升级订单: {existing_order.id}")
delta_amount = target_amount_cents - original.amount_cents
upgrade_id = generate_order_id()
source_tags = list(original.tags or [])
if "upgraded" not in source_tags:
source_tags.append("upgraded")
marker = f"升级至 {target_service_version},升级单 {upgrade_id}"
source_note_prefix = (original.notes + "\n") if original.notes else ""
source_note = f"{source_note_prefix}{marker}"
upgraded = Order(
id=upgrade_id,
source=original.source,
external_id=None,
service_version=target_service_version,
amount_cents=delta_amount,
status="pending",
customer_name=original.customer_name,
customer_phone=original.customer_phone,
customer_wechat=original.customer_wechat,
candidate_name=original.candidate_name,
candidate_id_card=original.candidate_id_card,
candidate_province=original.candidate_province,
candidate_score=original.candidate_score,
candidate_rank=original.candidate_rank,
candidate_subjects=list(original.candidate_subjects),
candidate_interests=original.candidate_interests,
candidate_strong_subjects=original.candidate_strong_subjects,
candidate_weak_subjects=original.candidate_weak_subjects,
candidate_family=original.candidate_family,
assigned_consultant=original.assigned_consultant,
plan_file=None,
audit_report=None,
pdf_path=None,
created_at=None,
paid_at=None,
started_at=None,
delivered_at=None,
completed_at=None,
notes=f"升级自 {original.id}"
if not original.notes
else f"{original.notes}\n升级自 {original.id}",
tags=list(original.tags or []),
upgrade_from=original.id,
)
with self.transaction():
self.update(
order_id,
{"notes": source_note, "tags": source_tags},
actor=actor,
reason=reason or f"upgrade_source:{target_service_version}",
)
return self.create(
upgraded,
actor=actor or "dao_upgrade",
reason=reason or f"upgrade_from:{order_id}",
)
# ------------------------------------------------------------------
# 状态转换
# ------------------------------------------------------------------

View File

@@ -112,6 +112,22 @@ def mask_name(value: Optional[str]) -> Optional[str]:
return s[0] + "**"
def mask_wechat(value: Optional[str]) -> Optional[str]:
"""微信号脱敏:保留前 2 后 2中段用 * 填充。"""
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
if len(s) <= 2:
return "*" * len(s)
if len(s) <= 4:
return s[0] + "*" * (len(s) - 2) + s[-1]
return s[:2] + "*" * (len(s) - 4) + s[-2:]
def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]:
"""对订单字典中已知的敏感字段统一脱敏。
@@ -128,6 +144,8 @@ def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]:
masked["candidate_id_card"] = mask_id_card(masked["candidate_id_card"])
if "customer_name" in masked and masked["customer_name"] is not None:
masked["customer_name"] = mask_name(masked["customer_name"])
if "customer_wechat" in masked and masked["customer_wechat"] is not None:
masked["customer_wechat"] = mask_wechat(masked["customer_wechat"])
if "candidate_name" in masked and masked["candidate_name"] is not None:
masked["candidate_name"] = mask_name(masked["candidate_name"])
return masked
@@ -137,5 +155,6 @@ __all__ = [
"mask_phone",
"mask_id_card",
"mask_name",
"mask_wechat",
"mask_sensitive_dict",
]

View File

@@ -0,0 +1,362 @@
"""gaokao-order-manager CLI tests (T4.3/T4.5).
覆盖 create/list/show/update/pay/deliver/stats/export 主链路,并验证默认输出走遮罩模式。
"""
from __future__ import annotations
import csv
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from data.orders.cli import main as cli_main
os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-cli")
PROJECT_ROOT = Path(__file__).resolve().parents[3]
SCRIPT_PATH = PROJECT_ROOT / "scripts" / "gaokao-order-manager"
@pytest.fixture
def tmp_db_path(tmp_path: Path) -> Path:
return tmp_path / "orders.db"
def _run_cli(
*args: str, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
return subprocess.run(
[sys.executable, str(SCRIPT_PATH), *args],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
env=merged_env,
)
def _load_json(stdout: str) -> dict:
return json.loads(stdout)
def test_create_list_show_update_pay_deliver_stats_flow(tmp_db_path: Path) -> None:
create = _run_cli(
"--db",
str(tmp_db_path),
"create",
"--source",
"web",
"--service-version",
"standard",
"--amount-cents",
"9900",
"--customer-name",
"张三",
"--customer-phone",
"13800001234",
"--candidate-name",
"李同学",
"--candidate-id-card",
"430102200501011234",
"--candidate-province",
"湖南",
"--candidate-score",
"578",
"--candidate-rank",
"12345",
"--candidate-subject",
"物理",
"--candidate-subject",
"化学",
"--note",
"首单",
"--tag",
"VIP",
)
assert create.returncode == 0, create.stderr
created = _load_json(create.stdout)
order_id = created["order"]["id"]
assert created["order"]["status"] == "pending"
assert created["order"]["customer_phone"] == "138****1234"
assert created["order"]["candidate_id_card"] == "430102********1234"
listed = _run_cli("--db", str(tmp_db_path), "list")
assert listed.returncode == 0, listed.stderr
listed_payload = _load_json(listed.stdout)
assert listed_payload["count"] == 1
assert listed_payload["orders"][0]["id"] == order_id
assert listed_payload["orders"][0]["customer_phone"] == "138****1234"
shown = _run_cli("--db", str(tmp_db_path), "show", order_id)
assert shown.returncode == 0, shown.stderr
shown_payload = _load_json(shown.stdout)
assert shown_payload["order"]["id"] == order_id
assert shown_payload["order"]["notes"] == "首单"
updated = _run_cli(
"--db",
str(tmp_db_path),
"update",
order_id,
"--assigned-consultant",
"consultant-a",
"--note",
"已分配顾问",
"--tag",
"已跟进",
"--tag",
"VIP",
)
assert updated.returncode == 0, updated.stderr
updated_payload = _load_json(updated.stdout)
assert updated_payload["order"]["assigned_consultant"] == "consultant-a"
assert updated_payload["order"]["notes"] == "已分配顾问"
assert updated_payload["order"]["tags"] == ["已跟进", "VIP"]
paid = _run_cli("--db", str(tmp_db_path), "pay", order_id, "--reason", "wechat-pay")
assert paid.returncode == 0, paid.stderr
paid_payload = _load_json(paid.stdout)
assert paid_payload["order"]["status"] == "paid"
assert paid_payload["order"]["paid_at"] is not None
delivered = _run_cli(
"--db",
str(tmp_db_path),
"deliver",
order_id,
"--reason",
"report-ready",
)
assert delivered.returncode == 0, delivered.stderr
delivered_payload = _load_json(delivered.stdout)
assert delivered_payload["order"]["status"] == "delivered"
assert delivered_payload["order"]["started_at"] is not None
assert delivered_payload["order"]["delivered_at"] is not None
assert [item["to_status"] for item in delivered_payload["history"]] == [
"pending",
"paid",
"serving",
"delivered",
]
stats = _run_cli("--db", str(tmp_db_path), "stats")
assert stats.returncode == 0, stats.stderr
stats_payload = _load_json(stats.stdout)
assert stats_payload["total_orders"] == 1
assert stats_payload["by_status"]["delivered"] == 1
assert stats_payload["by_source"]["web"] == 1
assert stats_payload["by_service_version"]["standard"] == 1
def test_show_missing_order_returns_nonzero_and_message(tmp_db_path: Path) -> None:
result = _run_cli("--db", str(tmp_db_path), "show", "missing-order")
assert result.returncode == 1
assert "missing-order" in result.stderr
def test_update_requires_at_least_one_mutation_field(tmp_db_path: Path) -> None:
created = _run_cli(
"--db",
str(tmp_db_path),
"create",
"--source",
"web",
"--service-version",
"basic",
"--amount-cents",
"4900",
)
order_id = _load_json(created.stdout)["order"]["id"]
result = _run_cli("--db", str(tmp_db_path), "update", order_id)
assert result.returncode == 2
assert "至少指定一个可更新字段" in result.stderr
def test_module_main_full_flow_covers_core_commands(
tmp_db_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
create_code = cli_main(
[
"--db",
str(tmp_db_path),
"create",
"--source",
"wechat",
"--service-version",
"basic",
"--amount-cents",
"4900",
"--customer-phone",
"13911112222",
"--tag",
"直连",
]
)
assert create_code == 0
created_payload = _load_json(capsys.readouterr().out)
order_id = created_payload["order"]["id"]
assert cli_main(["--db", str(tmp_db_path), "list"]) == 0
list_payload = _load_json(capsys.readouterr().out)
assert list_payload["count"] == 1
assert (
cli_main(
[
"--db",
str(tmp_db_path),
"update",
order_id,
"--assigned-consultant",
"consultant-b",
"--note",
"待支付",
]
)
== 0
)
update_payload = _load_json(capsys.readouterr().out)
assert update_payload["order"]["assigned_consultant"] == "consultant-b"
assert cli_main(["--db", str(tmp_db_path), "show", order_id]) == 0
show_payload = _load_json(capsys.readouterr().out)
assert show_payload["order"]["customer_phone"] == "139****2222"
assert cli_main(["--db", str(tmp_db_path), "pay", order_id]) == 0
pay_payload = _load_json(capsys.readouterr().out)
assert pay_payload["order"]["status"] == "paid"
assert cli_main(["--db", str(tmp_db_path), "deliver", order_id]) == 0
deliver_payload = _load_json(capsys.readouterr().out)
assert deliver_payload["order"]["status"] == "delivered"
stats_code = cli_main(["--db", str(tmp_db_path), "--human", "stats"])
captured = capsys.readouterr()
assert stats_code == 0
assert "total_orders: 1" in captured.out
assert '"wechat": 1' in captured.out
def test_upgrade_command_creates_delta_order_and_marks_source(
tmp_db_path: Path,
) -> None:
created = _run_cli(
"--db",
str(tmp_db_path),
"create",
"--source",
"web",
"--service-version",
"basic",
"--amount-cents",
"4900",
"--customer-name",
"张三",
"--customer-phone",
"13800001234",
"--candidate-name",
"李同学",
"--note",
"49 元首单",
"--tag",
"首单",
)
assert created.returncode == 0, created.stderr
order_id = _load_json(created.stdout)["order"]["id"]
upgraded = _run_cli(
"--db",
str(tmp_db_path),
"upgrade",
order_id,
"--service-version",
"standard",
"--target-amount-cents",
"9900",
"--reason",
"upgrade_to_standard",
)
assert upgraded.returncode == 0, upgraded.stderr
upgraded_payload = _load_json(upgraded.stdout)
upgrade_order = upgraded_payload["order"]
source_order = upgraded_payload["source_order"]
assert upgrade_order["upgrade_from"] == order_id
assert upgrade_order["service_version"] == "standard"
assert upgrade_order["amount_cents"] == 5000
assert upgrade_order["status"] == "pending"
assert upgrade_order["customer_phone"] == "138****1234"
assert source_order["id"] == order_id
assert "upgraded" in source_order["tags"]
assert upgrade_order["id"] in (source_order["notes"] or "")
listed = _run_cli("--db", str(tmp_db_path), "list")
listed_payload = _load_json(listed.stdout)
assert listed_payload["count"] == 2
def test_export_command_writes_minimal_csv_report(tmp_db_path: Path) -> None:
created = _run_cli(
"--db",
str(tmp_db_path),
"create",
"--source",
"school",
"--service-version",
"premium",
"--amount-cents",
"19900",
"--customer-name",
"王家长",
)
assert created.returncode == 0, created.stderr
order_id = _load_json(created.stdout)["order"]["id"]
export_path = tmp_db_path.parent / "orders-report.csv"
exported = _run_cli(
"--db",
str(tmp_db_path),
"export",
"--output",
str(export_path),
"--status",
"pending",
"--source",
"school",
)
assert exported.returncode == 0, exported.stderr
payload = _load_json(exported.stdout)
assert payload["format"] == "csv"
assert payload["rows"] == 1
assert payload["output"] == str(export_path)
assert export_path.exists()
with export_path.open("r", encoding="utf-8-sig", newline="") as fh:
rows = list(csv.DictReader(fh))
assert rows == [
{
"订单号": order_id,
"渠道": "school",
"金额": "199.00",
"状态": "pending",
"创建时间": rows[0]["创建时间"],
}
]
assert rows[0]["创建时间"]
def test_module_main_missing_show_returns_error(
tmp_db_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
result = cli_main(["--db", str(tmp_db_path), "show", "missing-order"])
captured = capsys.readouterr()
assert result == 1
assert "missing-order" in captured.err

View File

@@ -326,6 +326,94 @@ class TestCRUD:
assert after.notes == "新备注"
class TestUpgradeOrder:
def test_upgrade_order_creates_delta_order_and_marks_source(
self, dao, sample_order
):
original = dao.create(
Order(**{
**sample_order.to_dict(True),
"id": generate_order_id(),
"service_version": "basic",
"amount_cents": 4900,
"status": "pending",
"notes": "49 元首单",
"tags": ["首单"],
})
)
upgraded = dao.upgrade_order(
original.id,
target_service_version="standard",
target_amount_cents=9900,
actor="qa",
reason="upgrade_to_standard",
)
assert upgraded.id != original.id
assert upgraded.upgrade_from == original.id
assert upgraded.service_version == "standard"
assert upgraded.amount_cents == 5000
assert upgraded.status == "pending"
assert upgraded.customer_name == original.customer_name
assert upgraded.customer_phone == original.customer_phone
assert upgraded.candidate_name == original.candidate_name
assert upgraded.paid_at is None
assert upgraded.started_at is None
assert upgraded.delivered_at is None
source_after = dao.get(original.id)
assert source_after.status == "pending"
assert "upgraded" in source_after.tags
assert upgraded.id in (source_after.notes or "")
assert dao.count() == 2
def test_upgrade_order_rejects_non_increasing_target_amount(
self, dao, sample_order
):
original = dao.create(
Order(**{
**sample_order.to_dict(True),
"id": generate_order_id(),
"service_version": "basic",
"amount_cents": 4900,
"status": "pending",
})
)
with pytest.raises(ValueError, match="高于原订单金额"):
dao.upgrade_order(
original.id,
target_service_version="standard",
target_amount_cents=4900,
)
def test_upgrade_order_rejects_second_upgrade_from_same_source(
self, dao, sample_order
):
original = dao.create(
Order(**{
**sample_order.to_dict(True),
"id": generate_order_id(),
"service_version": "basic",
"amount_cents": 4900,
"status": "pending",
})
)
dao.upgrade_order(
original.id,
target_service_version="standard",
target_amount_cents=9900,
)
with pytest.raises(ValueError, match="已存在升级订单"):
dao.upgrade_order(
original.id,
target_service_version="premium",
target_amount_cents=19900,
)
# ---------------------------------------------------------------------------
# 4. 状态机守护
# ---------------------------------------------------------------------------
@@ -474,7 +562,7 @@ class TestTransaction:
class TestUpsert:
def _make_order(self, **overrides) -> Order:
defaults = dict(
defaults: dict[str, Any] = dict(
id=generate_order_id(),
source="xianyu",
external_id="EXT-1001",

View File

@@ -18,6 +18,7 @@ from data.orders.masking import (
mask_name,
mask_phone,
mask_sensitive_dict,
mask_wechat,
)
@@ -154,11 +155,23 @@ def test_mask_name_mixed_chinese_and_digits_treats_as_non_cjk():
# ---------------------- mask_sensitive_dict ----------------------
def test_mask_wechat_keeps_prefix_suffix():
assert mask_wechat("wx-li") == "wx*li"
assert mask_wechat("wechat_user") == "we*******er"
def test_mask_wechat_none_and_empty_are_safe():
assert mask_wechat(None) is None
assert mask_wechat("") == ""
assert mask_wechat("ab") == "**"
def test_mask_sensitive_dict_handles_all_known_fields():
data = {
"customer_phone": "13800001234",
"candidate_id_card": "430102200501011234",
"customer_name": "张三",
"customer_wechat": "wx-li",
"candidate_name": "李四光",
"customer_phone_hash": "abc",
"amount_cents": 1000,
@@ -167,6 +180,7 @@ def test_mask_sensitive_dict_handles_all_known_fields():
assert out["customer_phone"] == "138****1234"
assert out["candidate_id_card"] == "430102********1234"
assert out["customer_name"] == "张*"
assert out["customer_wechat"] == "wx*li"
assert out["candidate_name"] == "李*光" # 3 字姓名 → 姓 + * + 名末字
assert out["customer_phone_hash"] == "abc"
assert out["amount_cents"] == 1000