Files
gaokao-volunteer-system/data/orders/cli.py
Hermes Agent 3f46811e49
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
release: cut v2.1
2026-06-13 14:49:58 +08:00

456 lines
15 KiB
Python

"""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())