diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8b31c49..09d9df6 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -169,7 +169,7 @@ OrderTask 约定: -- 中↔英映射只在写入 Excel 或渲染 GUI 的边界处发生(建议由 `TaskStatus` 提供 `display_name` 属性集中维护),核心逻辑只比较英文枚举成员。 +- 中↔英映射只在写入 Excel 或渲染 GUI 的边界处发生,由 `TaskStatus` 的 `display_name` 属性与 `from_display` 反查集中维护(单一事实源),核心逻辑只比较英文枚举成员。 - `OrderTask.status` 字段类型为 `TaskStatus`,不直接存中文字符串。 - 注意区分「任务状态」与「执行器状态」:上表是单条任务的 `status`;批次级的「暂停」属于执行器状态,不进入本枚举(见 §4.5)。 diff --git a/docs/current-state.md b/docs/current-state.md index e97d950..2608d9d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -7,10 +7,10 @@ ## 当前快照 - 日期:2026-06-24 -- 阶段:Phase 0 地基(T-001 完成)/ 原型验证前 +- 阶段:Phase 0 地基(T-001、T-002 完成)/ 原型验证前 - 技术栈:Python 3.10(骨架已跑通);计划接入 PySide6 + `uiautomator2` + ADB + `openpyxl` -- 生产代码:已初始化最小骨架(`src/main.py` 最小入口) -- 测试:标准库 `unittest` 冒烟测试(`tests/test_smoke.py`) +- 生产代码:骨架 + 核心数据模型(`src/main.py` 入口、`src/core/` 状态枚举与任务模型) +- 测试:标准库 `unittest`,覆盖冒烟与核心模型(`tests/test_smoke.py`、`tests/test_models.py`) - 数据:尚未提供真实 Excel 样例 ## 当前目录要点 @@ -18,7 +18,7 @@ | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | 项目规范化文档,已按 harness coding 文档风格建立。 | -| `src/` | 已有 | 已建最小入口 `src/main.py`;GUI/执行器/Excel/Android 模块由后续任务添加。 | +| `src/` | 已有 | `main.py` 入口 + `core/`(`status.py` 状态枚举、`models.py` 任务模型);GUI/执行器/Excel/Android 模块后续添加。 | | `tests/` | 已有 | 已建冒烟测试 `tests/test_smoke.py`(unittest)。 | | `requirements.txt` | 已有 | 依赖清单;当前零第三方运行时依赖,含依赖纪律注释。 | | `samples/` | 待建 | 脱敏 Excel 样例。 | @@ -29,9 +29,9 @@ 任务定义以 [`../tasks.md`](../tasks.md) 为准,执行记录以 [`../progress.md`](../progress.md) 为准。 -- 已完成:项目文档初始化;`T-001 初始化 Python 项目骨架`。 +- 已完成:项目文档初始化;`T-001 初始化 Python 项目骨架`;`T-002 建立核心数据模型和状态枚举`。 - 正在进行:无。 -- 下一个可领取任务:`T-002 建立核心数据模型和状态枚举`。 +- 下一个可领取任务:`T-003 建立日志和运行产物目录策略`。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index 0f345db..3fb73cc 100644 --- a/progress.md +++ b/progress.md @@ -14,9 +14,9 @@ ## 当前摘要 - 日期:2026-06-24 -- 阶段:Phase 0 地基(T-001 完成)/ 原型验证前 -- 当前任务:无(T-001 已完成) -- 下一个任务:`T-002 建立核心数据模型和状态枚举` +- 阶段:Phase 0 地基(T-001、T-002 完成)/ 原型验证前 +- 当前任务:无(T-002 已完成) +- 下一个任务:`T-003 建立日志和运行产物目录策略` - 当前阻塞:尚未提供真实 Excel 样例、Android 真机和测试商品链接 ## 进度记录 @@ -58,6 +58,14 @@ - `python3 src/main.py` → 正常输出,exit=0 - 范围:未提前创建 models/GUI/Android 等后续任务模块;`logs/`、`artifacts/`、`samples/` 归 T-003。 +### 2026-06-24 · T-002 建立核心数据模型和状态枚举(DONE) + +- 完成:`src/core/status.py` 定义 `TaskStatus`(六态,英文成员 + `display_name`/`from_display` 中英映射,单一事实源);`src/core/models.py` 定义 `OrderTask` dataclass,字段集合/类型对齐 `04-architecture.md` §3.3。 +- 测试:`tests/test_models.py` 覆盖枚举完整性、中英映射双向往返、未知值报错、`OrderTask` 默认值与 `sku_items` 实例独立性。 +- 文档:`04-architecture.md` §3.4 约定同步为 `display_name` + `from_display`。 +- 范围:未含 `PaymentConfig`/`PaymentMode`/`RunnerState`(属支付与执行器任务,按需引入)。 +- 验证:`python3 -m compileall src` OK;`python3 -m unittest discover -s tests -t .` → Ran 10 tests, OK。 + ## 阻塞与风险 - 真实 Excel 表头待提供。 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..733a793 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +"""核心模型、状态枚举与任务编排。""" diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..9d114c3 --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,47 @@ +"""核心数据模型。 + +OrderTask 对齐 docs/04-architecture.md §3.3 的内部任务模型字段集合与类型。 +字段顺序因 dataclass「无默认值字段须在有默认值字段之前」的约束做了分组调整, +字段集合、类型与语义与文档一致。支付模式不挂在本模型上(见 §3.3 说明与 §4.4 PaymentConfig)。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal + +from src.core.status import TaskStatus + + +@dataclass +class OrderTask: + """一条已审核的拼多多下单任务。 + + 必填字段(来自 Excel 必填列)在前,其余可选字段与执行期回填字段带默认值。 + 本模型只承载数据,不做业务校验;表头/数量等校验在 Excel 导入模块(T-201)。 + """ + + # 必填:来自 Excel 的核心任务信息 + task_id: str + row_index: int + product_url: str + sku_text: str + quantity: int + + # 可选:来自 Excel 的辅助信息 + sku_items: dict[str, str] = field(default_factory=dict) + product_name: str | None = None + receiver: str | None = None + phone: str | None = None + address: str | None = None + note: str | None = None + expected_amount: Decimal | None = None + max_pay_amount: Decimal | None = None + + # 执行期回填 + status: TaskStatus = TaskStatus.PENDING + order_no: str | None = None + fail_reason: str | None = None + screenshot_path: str | None = None + started_at: datetime | None = None + finished_at: datetime | None = None diff --git a/src/core/status.py b/src/core/status.py new file mode 100644 index 0000000..85d0de9 --- /dev/null +++ b/src/core/status.py @@ -0,0 +1,50 @@ +"""任务状态枚举(TaskStatus)。 + +与 docs/04-architecture.md §3.4 的状态机和中英映射表保持一致: +代码使用英文枚举成员,中文仅用于 GUI 展示与 Excel 回写。 +中↔英映射集中在本模块维护(display_name / from_display),单一事实源, +核心逻辑只比较英文枚举成员,不直接比较中文字符串。 +""" +from __future__ import annotations + +from enum import Enum + + +class TaskStatus(Enum): + """单条下单任务的状态。""" + + PENDING = "pending" + RUNNING = "running" + MANUAL = "manual" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" + + @property + def display_name(self) -> str: + """返回写入 Excel / 展示在 GUI 的中文名。""" + return _DISPLAY_NAMES[self] + + @classmethod + def from_display(cls, text: str) -> "TaskStatus": + """由中文显示名反查枚举,供读回结果文件等边界场景使用。 + + 未知文本抛 ValueError,不静默兜底,避免吞掉脏数据。 + """ + try: + return _BY_DISPLAY[text] + except KeyError: + raise ValueError(f"未知的任务状态显示名:{text!r}") from None + + +# 中英映射集中在此,作为单一事实源,避免散落各处导致漂移。 +_DISPLAY_NAMES: dict[TaskStatus, str] = { + TaskStatus.PENDING: "待执行", + TaskStatus.RUNNING: "执行中", + TaskStatus.MANUAL: "待人工", + TaskStatus.SUCCESS: "成功", + TaskStatus.FAILED: "失败", + TaskStatus.CANCELLED: "已取消", +} + +_BY_DISPLAY: dict[str, TaskStatus] = {zh: status for status, zh in _DISPLAY_NAMES.items()} diff --git a/tasks.md b/tasks.md index 83467fe..3e69446 100644 --- a/tasks.md +++ b/tasks.md @@ -21,7 +21,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-001 | 初始化 Python 项目骨架 | - | 建立 `src/`、`tests/`、`requirements.txt`;可运行最小入口;不引入无关依赖;用**真实可运行命令**替换 `00-ai-start-here.md`、`03-tech-stack.md`、`05-coding-rules.md`、`current-state.md` 中的占位符验证命令 | DONE | -| T-002 | 建立核心数据模型和状态枚举 | T-001 | `OrderTask`、`TaskStatus` 与 `04-architecture.md` 一致;有基础单元测试 | TODO | +| T-002 | 建立核心数据模型和状态枚举 | T-001 | `OrderTask`、`TaskStatus` 与 `04-architecture.md` 一致;有基础单元测试 | DONE | | T-003 | 建立日志和运行产物目录策略 | T-001 | 日志、截图、UI XML 输出路径清晰;不记录敏感信息 | TODO | ## Phase 1 · 最高风险验证 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..04cfb0f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,88 @@ +"""核心数据模型与状态枚举测试(T-002)。""" +import unittest +from datetime import datetime +from decimal import Decimal + +from src.core.models import OrderTask +from src.core.status import TaskStatus + + +class TaskStatusTest(unittest.TestCase): + # 与 04-architecture.md §3.4 中英映射表保持一致 + EXPECTED = { + TaskStatus.PENDING: "待执行", + TaskStatus.RUNNING: "执行中", + TaskStatus.MANUAL: "待人工", + TaskStatus.SUCCESS: "成功", + TaskStatus.FAILED: "失败", + TaskStatus.CANCELLED: "已取消", + } + + def test_members_complete(self): + """枚举成员恰好为文档定义的六个。""" + self.assertEqual(set(TaskStatus), set(self.EXPECTED)) + + def test_display_name(self): + for status, zh in self.EXPECTED.items(): + self.assertEqual(status.display_name, zh) + + def test_display_names_unique(self): + names = [s.display_name for s in TaskStatus] + self.assertEqual(len(names), len(set(names))) + + def test_from_display_roundtrip(self): + for status in TaskStatus: + self.assertIs(TaskStatus.from_display(status.display_name), status) + + def test_from_display_unknown_raises(self): + with self.assertRaises(ValueError): + TaskStatus.from_display("不存在的状态") + + +class OrderTaskTest(unittest.TestCase): + def _minimal(self) -> OrderTask: + return OrderTask( + task_id="T1", + row_index=2, + product_url="https://example.com/p", + sku_text="颜色=黑色;尺码=XL", + quantity=1, + ) + + def test_defaults(self): + task = self._minimal() + self.assertEqual(task.status, TaskStatus.PENDING) + self.assertEqual(task.sku_items, {}) + self.assertIsNone(task.order_no) + self.assertIsNone(task.expected_amount) + + def test_sku_items_not_shared(self): + """default_factory 确保每个实例拥有独立 dict,不互相串改。""" + a = self._minimal() + b = self._minimal() + a.sku_items["颜色"] = "黑色" + self.assertEqual(b.sku_items, {}) + + def test_full_construction(self): + task = OrderTask( + task_id="T2", + row_index=3, + product_url="https://example.com/p2", + sku_text="套餐=单件", + quantity=2, + sku_items={"套餐": "单件"}, + product_name="测试商品", + expected_amount=Decimal("9.90"), + max_pay_amount=Decimal("10.00"), + status=TaskStatus.SUCCESS, + order_no="2026000001", + started_at=datetime(2026, 6, 24, 10, 0, 0), + ) + self.assertEqual(task.quantity, 2) + self.assertEqual(task.expected_amount, Decimal("9.90")) + self.assertEqual(task.status, TaskStatus.SUCCESS) + self.assertEqual(task.sku_items["套餐"], "单件") + + +if __name__ == "__main__": + unittest.main()