release: cut v2.1
This commit is contained in:
@@ -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 warning,data/ 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
455
data/orders/cli.py
Normal 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())
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 状态转换
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
362
data/orders/tests/test_cli.py
Normal file
362
data/orders/tests/test_cli.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user