commit 26ac889b4a5ad512b14031db804105bf56629c18 Author: Yuuuu <1569752928@qq.com> Date: Thu Aug 13 11:12:15 2026 +0800 chore: initial import of AutoRpa state center and YiSaiQi package Import the existing state center, web UI, YiSaiQi callback skeleton, and project docs. Ignore local venvs, node_modules, recordings, and the duplicate state_center copy. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e560fd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# OS / editor +.DS_Store +.idea/ +.vscode/ +*.swp + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +.venv_test/ +.whisper_venv/ + +# Node +node_modules/ +state_center_web/dist/ + +# Local runtime / agent +.runtime/ +.claude/ +.superpowers/ + +# Duplicate / scratch +state_center_副本/ + +# Media (keep docs, drop recordings and extracted frames) +*.mp4 +video_frames_v2/ diff --git a/PARALLEL_DEV_PLAN.md b/PARALLEL_DEV_PLAN.md new file mode 100644 index 0000000..c690390 --- /dev/null +++ b/PARALLEL_DEV_PLAN.md @@ -0,0 +1,367 @@ +# 状态中心 ↔ 艺赛旗并行开发方案 + +## 核心原则:契约驱动,各自独立,最后对接 + +双方只通过 **HTTP 回调契约** 交互,这个契约在交接文档里已经明确定义。在对接前,双方各自用假数据/假回调验证自己的逻辑,互不阻塞。 + +--- + +## 对接契约(双方共同遵守) + +### 基础信息 +- **状态中心地址**:联调时给艺赛旗实际 IP,如 `http://192.168.x.x:8000` +- **鉴权**:所有回调必须带 header `X-Agent-Token: `(默认 `dev-agent-token`,上线换正式值) +- **幂等键**:`complete` 回调可带 `idempotencyKey`,重发同键不会二次推进 + +### 三个回调接口(艺赛旗调状态中心) + +#### 1. 节点开始 +```bash +POST /api/jobs/{jobId}/nodes/{nodeCode}/start +Content-Type: application/json +X-Agent-Token: dev-agent-token + +{ + "attemptNo": 1, + "executorPlatform": "iS-RPA", + "operator": "agent" +} +``` + +#### 2. 节点成功 +```bash +POST /api/jobs/{jobId}/nodes/{nodeCode}/complete +Content-Type: application/json +X-Agent-Token: dev-agent-token + +{ + "attemptNo": 1, + "executorRunId": "YD-RUN-001", + "idempotencyKey": "uuid-xxx", + "businessRefs": { + "contractSystemNo": "HT2026...", // 三把钥匙,对应节点必传 + "rebateOrderNo": "FL2026...", + "m5OrderNo": "M5-xxx", + "receiptNoticeNo": "SH-xxx" + }, + "summary": { "orderCount": 12 }, + "artifacts": [ + { + "artifactType": "SOURCE_EXCEL", + "storagePath": "/data/jobs/xxx/orders.xlsx", + "fileHash": "sha256:abc", + "visibleRoles": ["PURCHASER", "PLANNER"], + "isMasked": false + } + ] +} +``` + +**返回**:`{"ok": true, "next_node": "OCR_EXTRACT"}` ← 艺赛旗拿到 `next_node` 继续跑下一节点 + +#### 3. 节点失败 +```bash +POST /api/jobs/{jobId}/nodes/{nodeCode}/fail +Content-Type: application/json +X-Agent-Token: dev-agent-token + +{ + "attemptNo": 1, + "errorCode": "SYS_LOGIN_FAIL", // 见交接文档错误码表 + "errorSummary": "验证码识别失败", + "retryable": true, // 系统类填 true,业务类填 false + "routeRole": "OPS", // OPS/PLANNER/PURCHASER + "artifacts": [] +} +``` + +**返回**:`{"ok": true, "job_status": "FAILED_RETRYABLE"}` + +### 必须回传的业务编号(三把钥匙) + +| 节点 | 必传字段 | 说明 | +| --- | --- | --- | +| CONTRACT_CREATE | `contractSystemNo` | 合同编号(后续返利、M5靠它串) | +| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 | +| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) | + +不传或传空,状态中心返回 **400 拒绝**。 + +### 错误码规范(部分,全表见交接文档) + +| 错误码 | 含义 | retryable | routeRole | +| --- | --- | --- | --- | +| SYS_LOGIN_FAIL | 登录失败 | true | OPS | +| SYS_PAGE_TIMEOUT | 页面超时 | true | OPS | +| UI_ELEMENT_NOT_FOUND | 页面改版找不到元素 | false | OPS | +| BIZ_CONTRACT_DISABLED | 供应商合同被禁 | false | PURCHASER | +| M5_QC_MISSING | M5质管资料缺失 | - | - | +| REBATE_DUP_CODE | 返利重复编码 | false | PURCHASER | + +**特殊规则**:`M5_QC_MISSING` 无条件转 `FAILED_MANUAL`,`retryable` 填什么都没用。 + +--- + +## 状态中心这边(你的任务) + +### 阶段1:独立验证(不依赖艺赛旗) +**时间**:1-2天 +**目标**:后端在真 MySQL 上用假回调跑通全链 + +#### 任务 +1. **环境准备** + - Docker 起 MySQL 8,执行 `schema.sql` 建库。 + - 配 `MYSQL_DSN`,起后端服务。 + - 前端 `npm run dev` 起来。 + +2. **用假回调跑通一单** + - 方式1:用 curl 按顺序调回调接口。 + - 方式2:用前端"开发:节点回调模拟"区域的按钮。 + - **路径**:建单 → ORDER_FETCH start/complete → PRODUCT_MATCH run(有 MULTI) → 人工确认 → SOURCE_ADD 人工提交 → CONTRACT_CREATE complete(带合同编号) → ... → ARCHIVE_JOB。 + - **验证点**: + - job 状态正确推进(PENDING → RUNNING → WAITING_USER → ...)。 + - 业务编号正确落库(合同编号、返利总单号、M5单号、收货通知单号)。 + - job_node 每个节点有历史记录,attempt_no 正确。 + - user_task 在该创建时创建,提交后关闭。 + - job_artifact 证据落库。 + - 条件节点(OCR_EXTRACT/SOURCE_ADD)正确跳过或执行。 + +3. **发现并修复真实问题** + - SQL 语法问题(如 MySQL 8 的保留字冲突)。 + - JSON 列序列化问题(Python dict → MySQL JSON → 前端拿到的是对象还是字符串)。 + - 事务回滚是否正确。 + - datetime 字段格式(MySQL 存的 `YYYY-MM-DD HH:MM:SS`,前端拿到的格式)。 + - 幂等逻辑是否真的防重(重复 complete 同一节点,状态不会乱)。 + +4. **记录真实 API 响应** + - 把 `GET /api/jobs/{id}` 的完整响应存下来,给 UI 优化做参考。 + +#### 产出 +- ✅ 一个能在真 MySQL 上跑通全链的后端(用假回调验证)。 +- ✅ 一份真实响应示例 + 发现的问题清单。 +- ✅ 前端能正常展示真实数据(不再依赖 `demoJobDetailMap`)。 + +### 阶段2:优化与补全(不依赖艺赛旗) +**时间**:2-3天 +**目标**:UI 优化 + 补缺失接口 + 加调度重试 + +#### 任务 +1. **UI 优化** + - 按刚才的方案,分区 + 可折叠 + 调试开关。 + - 基于阶段1 的真实响应,确保所有字段正确展示。 + +2. **补缺失接口** + - `GET /api/jobs/{id}/match/unmatched`:返回该 job 未命中的订单行列表。 + - 其他小接口(如果阶段1 发现有遗漏)。 + +3. **调度与重试(任务6)** + - 定时建单:每天早上扫前一天订单。 + - 系统类错误自动重试(`FAILED_RETRYABLE` 状态的 job,重试 1-2 次)。 + - 超时升级:合同审批超时、人工待办超时转 OPS。 + +#### 产出 +- ✅ 优化后的前端(信息分层清晰,调试模式可开关)。 +- ✅ 完整的接口(验收点名的都实现)。 +- ✅ 调度器能定时触发建单和重试。 + +### 阶段3:准备对接(等艺赛旗阶段2 完成) +**时间**:半天 +**目标**:给艺赛旗提供联调环境 + +#### 任务 +1. **部署联调环境** + - 把后端部署到艺赛旗能访问的机器(或你本机开端口映射)。 + - 给艺赛旗提供:基础地址(如 `http://192.168.x.x:8000`) + 正式 `X-Agent-Token`。 + +2. **准备测试数据** + - 在库里建几个不同类型的 job: + - 医药城线上单(会跳过 OCR_EXTRACT)。 + - 线下单(会走 OCR_EXTRACT)。 + - 有对码 MULTI 的单。 + - 缺货源的单(会走 SOURCE_ADD)。 + - 每个 job 停在 ORDER_FETCH 节点,等艺赛旗来调 start/complete 推进。 + +3. **写联调检查表** + - 逐节点列验收点,双方对着检查表勾。 + +#### 产出 +- ✅ 一个可供艺赛旗调用的联调环境。 +- ✅ 测试数据 + 联调检查表。 + +--- + +## 艺赛旗那边(你要转述给他们) + +### 阶段1:独立开发(不依赖状态中心) +**时间**:2-3周(他们的活重) +**目标**:每个节点的页面自动化都能独立跑通 + +#### 任务 +1. **理解回调契约** + - 仔细读 `给艺赛旗的开发交接.md`。 + - 确认三个回调接口的字段、错误码表。 + +2. **逐节点开发页面自动化** + - **节点顺序**(他们的优先级): + 1. ORDER_FETCH(订单获取):登录医药城/小药药 → 导出 Excel → 存证据。 + 2. CONTRACT_CREATE(合同建单):登录合同系统 → 填表 → 上传 PDF(医药城) → 提交送审 → 拿到合同编号。 + 3. REBATE_HEADER(返利总单):登录返利中心 → 按合同编号查总单 → 填必填项 → 保存 → 拿到返利总单号。 + 4. REBATE_IMPORT(返利细单):下载模板 → 填字段 → 上传 → 读成功/失败行。 + 5. REBATE_SUBMIT(返利送审):点"提交送审"按钮。 + 6. M5_CREATE_ORDER(M5建单):桌面自动化 → 录单 → **停,不保存**。 + 7. CONTRACT_APPROVE_WAIT(合同审批):定时查审批状态(到点被状态中心扫到时才查一次)。 + + - **每个节点独立验证**: + - 不调状态中心,在自己环境里跑(比如用测试账号登录真实系统,手工给初始数据)。 + - 验证能拿到业务编号(合同编号、返利总单号等)。 + - 验证错误情况能识别出正确的错误码(如登录失败是 `SYS_LOGIN_FAIL`,合同被禁是 `BIZ_CONTRACT_DISABLED`)。 + +3. **本地模拟回调** + - 自己起个假的状态中心(或者直接用 curl 打印 JSON),验证回调 payload 格式对不对。 + - 示例(他们自己写个小脚本验证): + ```python + # 假的状态中心 mock.py + from flask import Flask, request, jsonify + app = Flask(__name__) + + @app.route('/api/jobs//nodes//complete', methods=['POST']) + def complete(job_id, node_code): + data = request.json + print(f"收到 {node_code} complete:", data) + # 检查必传字段 + if node_code == 'CONTRACT_CREATE' and not data.get('businessRefs', {}).get('contractSystemNo'): + return jsonify({"error": "缺少合同编号"}), 400 + return jsonify({"ok": True, "next_node": "下一节点"}) + + app.run(port=8000) + ``` + +#### 产出 +- ✅ 每个节点的页面自动化脚本(iS-RPA 流程)。 +- ✅ 能正确识别各种错误并返回对应错误码。 +- ✅ 能拿到三把钥匙(合同编号、返利总单号、收货通知单号)。 + +### 阶段2:准备对接(等状态中心阶段3 准备好) +**时间**:1天 +**目标**:改成调真实状态中心接口 + +#### 任务 +1. **获取联调环境信息** + - 从状态中心拿到:基础地址 + `X-Agent-Token`。 + +2. **改造回调逻辑** + - 把阶段1 的假回调改成调真实状态中心接口。 + - **关键**:拿到 `complete` 返回的 `next_node`,继续跑下一节点(不要自己写死顺序)。 + +3. **小范围试跑** + - 先跑一个最简单的节点(如 ORDER_FETCH),验证回调能调通、状态中心能正确推进。 + +#### 产出 +- ✅ 能调通状态中心回调接口的 RPA 脚本。 + +--- + +## 联调阶段(双方一起) + +**时间**:3-5天 +**目标**:端到端跑通一单真实流程 + +### 任务 +1. **按节点逐个验证** + - 顺序:ORDER_FETCH → (OCR_EXTRACT) → PRODUCT_MATCH(状态中心内部) → (SOURCE_ADD 人工) → CONTRACT_CREATE → ... + - 每个节点验证: + - RPA 能正确调 start/complete/fail。 + - 状态中心正确推进到下一节点或转人工。 + - 业务编号正确落库。 + - 证据正确存储。 + +2. **跑完整链** + - 医药城线上单(跳过 OCR)。 + - 线下单(走 OCR)。 + - 有对码 MULTI 的单(走人工确认)。 + - 缺货源的单(走 SOURCE_ADD 人工)。 + - 合同审批等待(定时查几次,验证不会死等)。 + +3. **异常场景** + - 登录失败 → 重试。 + - 页面改版找不到元素 → 转 OPS。 + - M5 质管缺失 → 直接 FAILED_MANUAL,不重试。 + - 返利重复编码 → 该单挂起,整批继续。 + +4. **对照验收规范逐条勾** + - 打开 `实施与验收规范.md`,每个节点的代码验收 + 业务验收两栏逐条过。 + +### 产出 +- ✅ 一个能端到端跑通的完整流程(从订单获取到归档)。 +- ✅ 验收报告(哪些通过,哪些有问题)。 + +--- + +## 时间线(并行开发) + +``` +第1周 第2周 第3周 +状态中心 │────阶段1────│───阶段2───│──阶段3──│────联调────│ + 真MySQL跑通 UI优化+补全 准备环境 与艺赛旗对接 + (1-2天) (2-3天) (半天) (3-5天) + +艺赛旗 │────────────阶段1────────────│─阶段2─│────联调────│ + 独立开发各节点页面自动化 改真回调 与状态中心对接 + (2-3周) (1天) (3-5天) +``` + +**关键点**: +- 前两周双方完全独立,互不阻塞。 +- 状态中心先做完(1周内),等艺赛旗追上。 +- 艺赛旗开发周期长(页面自动化细节多),但不影响状态中心进度。 +- 第3周开始联调,双方都已在各自环境验证过,对接就是走形式。 + +--- + +## 给艺赛旗的检查清单(转述) + +在开始联调前,艺赛旗必须自证能做到: + +### 功能检查 +- [ ] 能登录医药城/小药药/合同系统/返利中心/M5,并正确处理验证码/超时。 +- [ ] 能导出订单 Excel 并存到指定路径。 +- [ ] 能建合同、上传 PDF(医药城)、拿到合同编号。 +- [ ] 能在返利中心按合同编号查总单、填表、拿到返利总单号。 +- [ ] 能下载返利细单模板、填字段、上传、读成功/失败行。 +- [ ] 能点返利送审按钮。 +- [ ] 能用桌面自动化在 M5 录单(但不保存)。 +- [ ] 能定时查合同审批状态(不是连续轮询,是到点查一次)。 + +### 回调检查 +- [ ] 知道何时调 start、何时调 complete、何时调 fail。 +- [ ] complete 时能正确带 `businessRefs`(三把钥匙对应节点必传)。 +- [ ] fail 时能正确识别错误码(`SYS_*` vs `BIZ_*`,`retryable` 填对)。 +- [ ] 拿到 `next_node` 后能继续跑下一节点(不写死顺序)。 +- [ ] 所有回调都带 `X-Agent-Token` header。 + +### 异常检查 +- [ ] 登录失败时返回 `SYS_LOGIN_FAIL`,`retryable=true`。 +- [ ] 页面改版找不到元素时返回 `UI_ELEMENT_NOT_FOUND`,`retryable=false`,`routeRole=OPS`。 +- [ ] M5 质管缺失时返回 `M5_QC_MISSING`(状态中心会直接转 FAILED_MANUAL,不重试)。 +- [ ] 供应商合同被禁时返回 `BIZ_CONTRACT_DISABLED`,`retryable=false`,`routeRole=PURCHASER`。 + +--- + +## 总结 + +**你现在要做的**: +1. 马上开始阶段1(真 MySQL 跑通),1-2天搞定。 +2. 接着做阶段2(UI 优化 + 补全),2-3天搞定。 +3. 然后做阶段3(准备联调环境),半天搞定。 +4. **这时你这边已经全部做完**,等艺赛旗追上(他们要 2-3 周)。 + +**艺赛旗要做的**(你转述给他们): +1. 阶段1:独立开发各节点页面自动化,2-3周。期间不需要状态中心配合,自己用假数据验证。 +2. 阶段2:拿到状态中心联调环境信息后,改成调真实接口,1天。 +3. 联调阶段:双方一起按检查表逐条验收,3-5天。 + +**不会互相阻塞**:你 1 周内做完你的,艺赛旗 2-3 周做完他们的,最后 3-5 天一起联调。 + +--- + +要我把这份文档发给艺赛旗吗?或者你先看看有没有要调整的。 diff --git a/docs/superpowers/plans/2026-08-10-source-add-match-idempotency-plan.md b/docs/superpowers/plans/2026-08-10-source-add-match-idempotency-plan.md new file mode 100644 index 0000000..1489e9f --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-source-add-match-idempotency-plan.md @@ -0,0 +1,116 @@ +# SOURCE_ADD / 对码幂等 / Alias 唯一态 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 `SOURCE_ADD` 有明确触发源,让 `PRODUCT_MATCH` 可重复跑且不重复落库,让 alias 沉淀成唯一当前态。 + +**Architecture:** 对码仍然留在 `app/main.py` 的状态中心内执行,但把“判定缺货源 / 对码执行 / 人工确认”拆成稳定的写入边界。`product_match` 以 `(job_id, order_item_id)` 作为幂等键,`mapping_config` 只保留一个 active alias 当前态,`job.needs_source_add` 作为 `SOURCE_ADD` 的唯一触发开关。 + +**Tech Stack:** Python, FastAPI, MySQL, pymysql, pytest/unittest + +--- + +### Task 1: 让对码重跑幂等 + +**Files:** +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql` +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py` +- Test: `/Users/yuuuu/Documents/AutoRpa/state_center/tests/test_match_engine.py` or a new focused test file + +- [ ] **Step 1: Write the failing test** + +```python +def test_match_run_replay_does_not_duplicate_product_match_rows(): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: fail because `product_match` lacks a stable dedupe key or the code still inserts duplicates. + +- [ ] **Step 3: Write minimal implementation** + +Add a unique key on `(job_id, order_item_id)`, switch `match_run` to upsert by that key, and keep `user_task` creation tied to `product_match_id`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: pass for the replay case. + +- [ ] **Step 5: Commit** + +```bash +git add /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/tests +git commit -m "fix: make product match replay idempotent" +``` + +### Task 2: 让 SOURCE_ADD 有明确触发源 + +**Files:** +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py` +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql` +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/给Trae的开发交接.md` + +- [ ] **Step 1: Write the failing test** + +```python +def test_source_add_only_triggers_when_job_flag_is_set(): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: fail because `needs_source_add` is not yet driven by a concrete write path. + +- [ ] **Step 3: Write minimal implementation** + +Introduce a single write path for `job.needs_source_add`, use it in `match_run` / confirm flow, and only let `_next_node()` expose `SOURCE_ADD` when that flag is set. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: pass for both the flagged and skipped cases. + +- [ ] **Step 5: Commit** + +```bash +git add /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/给Trae的开发交接.md +git commit -m "fix: make source add trigger explicit" +``` + +### Task 3: 让 alias 沉淀成唯一当前态 + +**Files:** +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/app/main.py` +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql` +- Modify: `/Users/yuuuu/Documents/AutoRpa/state_center/README.md` + +- [ ] **Step 1: Write the failing test** + +```python +def test_alias_confirm_replaces_existing_active_alias_for_same_key(): + ... +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: fail because multiple active aliases for the same `config_key` are still possible. + +- [ ] **Step 3: Write minimal implementation** + +Make confirm flow deactivate the previous active alias for the same key before inserting the new one, and make alias loading deterministic by reading only the latest active row per key. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest /Users/yuuuu/Documents/AutoRpa/state_center/tests -q` +Expected: pass with one active alias per key. + +- [ ] **Step 5: Commit** + +```bash +git add /Users/yuuuu/Documents/AutoRpa/state_center/app/main.py /Users/yuuuu/Documents/AutoRpa/state_center/sql/schema.sql /Users/yuuuu/Documents/AutoRpa/state_center/README.md +git commit -m "fix: keep alias mapping deterministic" +``` diff --git a/docs/superpowers/plans/2026-08-10-state-center-web-state-machine.md b/docs/superpowers/plans/2026-08-10-state-center-web-state-machine.md new file mode 100644 index 0000000..73fde29 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-state-center-web-state-machine.md @@ -0,0 +1,111 @@ +# State Center Web State Machine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the browser-based Web state machine for `state_center`: job list, timeline, task list, and task/node actions. + +**Architecture:** Keep the existing FastAPI backend as the source of truth for workflow state. Add small, focused backend endpoints for timeline, jobs, tasks, and dev ingestion, then build a React/Vite frontend that only renders backend-computed state and posts actions back. Do not move business rules into the frontend. + +**Tech Stack:** Python 3.13, FastAPI, Pydantic, pymysql, pytest, Node.js, Vite, React, TypeScript, plain CSS. + +--- + +### Task 1: Add backend task/timeline data contracts + +**Files:** +- Modify: `state_center/sql/schema.sql` +- Modify: `state_center/app/main.py` +- Create: `state_center/tests/test_web_state_machine.py` + +- [ ] **Step 1: Write the failing test** + +Add tests for: +- `GET /api/jobs/{job_id}/timeline` returns all 13 nodes with backend-computed statuses. +- `POST /api/tasks/{task_id}/submit` stores `submitted_result_json` and returns the stored result on duplicate `submitToken`. +- `POST /api/jobs/{job_id}/items` rejects unsafe replace-after-match behavior. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest state_center/tests/test_web_state_machine.py -v` +Expected: fail because endpoints/fields do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Add the schema column for stored task results, then implement the backend helpers and endpoints in `main.py`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest state_center/tests/test_web_state_machine.py -v` +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add state_center/sql/schema.sql state_center/app/main.py state_center/tests/test_web_state_machine.py +git commit -m "feat: add web state machine backend contracts" +``` + +### Task 2: Add browser-ready frontend app + +**Files:** +- Create: `state_center_web/package.json` +- Create: `state_center_web/vite.config.ts` +- Create: `state_center_web/index.html` +- Create: `state_center_web/src/main.tsx` +- Create: `state_center_web/src/App.tsx` +- Create: `state_center_web/src/api/client.ts` +- Create: `state_center_web/src/components/JobList.tsx` +- Create: `state_center_web/src/components/JobTimeline.tsx` +- Create: `state_center_web/src/components/TaskList.tsx` +- Create: `state_center_web/src/styles.css` + +- [ ] **Step 1: Write the failing test** + +Add a lightweight frontend build check and a small unit test for timeline status mapping. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: fail because the app does not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Create the Vite app, route-less operational UI, and fetch-based API client. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add state_center_web +git commit -m "feat: add web state machine frontend" +``` + +### Task 3: Wire local dev workflow + +**Files:** +- Create: `state_center_web/README.md` +- Modify: `state_center/README.md` + +- [ ] **Step 1: Write the failing test** + +Not required; this task is documentation and run instructions only. + +- [ ] **Step 2: Write minimal implementation** + +Document the start commands for the FastAPI backend and Vite frontend on Windows/macOS. + +- [ ] **Step 3: Verify manually** + +Start backend and frontend locally, open the browser app, and confirm the timeline and task list render. + +- [ ] **Step 4: Commit** + +```bash +git add state_center/README.md state_center_web/README.md +git commit -m "docs: add web state machine runbook" +``` + diff --git a/docs/superpowers/plans/2026-08-11-business-overview-ui-plan.md b/docs/superpowers/plans/2026-08-11-business-overview-ui-plan.md new file mode 100644 index 0000000..9fb3b8b --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-business-overview-ui-plan.md @@ -0,0 +1,149 @@ +# 业务优先单页收敛 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把状态中心页面收敛成“业务先看懂单据、运维再下钻”的单页结构,首屏只保留结论、关键编号、当前待办和简化流程,证据/节点详情/验收收口默认折叠。 + +**Architecture:** 页面以单据概览为主视图,流程图和当前待办保持可见,但证据、节点详情、验收清单移到折叠区。状态计算仍留在 `stateMachine.ts`,组件只消费派生结果,不在 JSX 里推导业务规则。视觉上减少同屏卡片数量,保留一条主叙事线。 + +**Tech Stack:** React 18 + TypeScript + CSS + 现有 FastAPI 数据契约 + +--- + +### Task 1: 提炼业务概览的数据模型 + +**Files:** +- Modify: `state_center_web/src/stateMachine.ts` +- Modify: `state_center_web/src/stateMachine.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```javascript +test('概览模型只保留首屏必要信息', () => { + const model = buildBusinessOverview(job, timeline, tasks, artifacts); + assert.equal(model.header.currentStatusText, '已完成'); + assert.equal(model.header.currentNodeText, '归档'); + assert.equal(model.referenceChain.length, 5); + assert.equal(model.primaryTask.title, '新增货源:重庆亿昊药业'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd state_center_web && node --test --experimental-strip-types src/stateMachine.test.mjs` +Expected: FAIL with missing `buildBusinessOverview` export. + +- [ ] **Step 3: Write minimal implementation** + +Implement `buildBusinessOverview()` in `stateMachine.ts` so it returns: +- 首屏头部摘要 +- 关键编号链 +- 当前待办 +- 简化的流程状态 +- 折叠区所需的计数摘要 + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd state_center_web && node --test --experimental-strip-types src/stateMachine.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add state_center_web/src/stateMachine.ts state_center_web/src/stateMachine.test.mjs +git commit -m "feat: add business overview view model" +``` + +### Task 2: 重排单页骨架为业务优先布局 + +**Files:** +- Modify: `state_center_web/src/App.tsx` +- Modify: `state_center_web/src/components/JobTimeline.tsx` +- Modify: `state_center_web/src/components/JobReferenceChain.tsx` +- Modify: `state_center_web/src/components/JobAcceptanceChecklist.tsx` +- Modify: `state_center_web/src/components/JobNodeDetailList.tsx` +- Modify: `state_center_web/src/components/JobEvidenceList.tsx` + +- [ ] **Step 1: Write the failing test** + +Use the existing browser mockup as the acceptance target: +- 首屏只显示一个业务结论区 +- 关键编号和当前待办在首屏可见 +- 证据、节点详情、验收清单默认收起 + +Add a component test or state snapshot asserting the new layout consumes the business overview model instead of rendering all detail blocks at equal weight. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs` +Expected: FAIL because the page still renders the old dense stack. + +- [ ] **Step 3: Write minimal implementation** + +Recompose `App.tsx` into: +- 顶部单据概览 +- 中间流程主区 + 当前待办 +- 底部三个折叠区:证据、节点详情、验收对照 + +Keep existing data loading and callbacks, but move the dense panels behind `
` or equivalent collapsible controls. Limit the header to the business summary, not all fields. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +- `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs` +- `cd state_center_web && ./node_modules/.bin/tsc --noEmit` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add state_center_web/src/App.tsx state_center_web/src/components/JobTimeline.tsx state_center_web/src/components/JobReferenceChain.tsx state_center_web/src/components/JobAcceptanceChecklist.tsx state_center_web/src/components/JobNodeDetailList.tsx state_center_web/src/components/JobEvidenceList.tsx +git commit -m "feat: simplify business overview layout" +``` + +### Task 3: 压缩视觉密度并做浏览器验收 + +**Files:** +- Modify: `state_center_web/src/styles.css` +- Optional modify: `state_center_web/src/components/*.tsx` only if a small class split is needed + +- [ ] **Step 1: Write the failing test** + +No new logic test needed; use a visual acceptance check: +- 业务结论区在首屏可见 +- 折叠区默认关闭 +- 屏幕上不同时出现过多等重卡片 + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd state_center_web && ./node_modules/.bin/vite build` +Expected: build passes before visual check, but browser screenshot should still show the dense layout, proving the need for CSS reduction. + +- [ ] **Step 3: Write minimal implementation** + +Reduce weight in CSS: +- 收窄主卡片数量 +- 给折叠区明显但低优先级的视觉权重 +- 让编号链和状态结论压在首屏 +- 让路径、证据、验收清单更像下钻信息,不像主内容 + +- [ ] **Step 4: Run test to verify it passes** + +Run: +- `cd state_center_web && ./node_modules/.bin/vite build` +- `cd state_center_web && ./node_modules/.bin/tsc --noEmit` +- `cd state_center_web && node --test --experimental-strip-types src/*.test.mjs` + +Then open the local app in the browser and verify: +- 首屏只有一条主叙事 +- 折叠区默认收起 +- 证据路径可点 + +- [ ] **Step 5: Commit** + +```bash +git add state_center_web/src/styles.css +git commit -m "feat: reduce dashboard density" +``` + diff --git a/docs/superpowers/specs/2026-08-10-state-center-web-state-machine-design.md b/docs/superpowers/specs/2026-08-10-state-center-web-state-machine-design.md new file mode 100644 index 0000000..666be75 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-state-center-web-state-machine-design.md @@ -0,0 +1,138 @@ +# State Center Web State Machine Design + +## Goal + +Build a browser-based Web application for the state center so users can see where each procurement RPA job is, inspect node details, handle open tasks, and manually advance supported human nodes. The Web app must run the same way on Windows and macOS through a standard browser. + +## Scope + +The first version is option B: status diagram + task list + node actions. + +In scope: +- A React/Vite frontend under `state_center_web/`. +- FastAPI endpoints needed by the frontend for state machine metadata, job search/list, task list, task submit, and manual node completion. +- A visual job detail page showing all 13 nodes, current node, executor owner, completed/running/waiting/failed states, and skipped conditional nodes. +- A task page for open tasks by role and status. +- Basic node action controls for development/demo only: create job, start/complete/fail RPA nodes with token, run product match, submit task actions, and complete manual nodes where allowed. +- Cross-platform scripts based on Node/npm and Python commands only; no Electron or OS-specific shell behavior. + +Out of scope for this first version: +- Evidence file browser as a full module. +- Enterprise WeChat login/JWT exchange. +- Real Oracle credentials and real external-system page automation. +- Desktop app packaging. + +Because real Oracle credentials are out of scope, local demo must include one explicit fixture path for the goods engine. The fixture loader is development-only and separate from `/api/match/reload-goods`; production still reads goods master data from Oracle only. + +## Architecture + +The existing FastAPI service remains the single owner of business state. The frontend never computes the next node by itself; it renders the backend state machine metadata and calls backend actions. 艺赛旗 remains a node executor: it calls `start`, `complete`, and `fail`; it does not own the global business state machine. + +```mermaid +flowchart LR + Browser["Browser Web App"] --> API["FastAPI State Center"] + API --> MySQL["MySQL rpa_center"] + API -. read only .-> Oracle["Oracle Goods Master"] + ISRPA["iS-RPA"] --> API + Human["Business User"] --> Browser +``` + +## Backend Design + +Keep `state_center/app/main.py` as the current entry point, but add small, focused endpoints instead of changing the existing callback contract. + +Add: +- `GET /api/state-machine`: returns `NODE_ORDER`, transitions, executor map, conditional node set, and display labels. +- `GET /api/jobs`: list/search jobs by `status`, `source_type`, `supplier_name`, `date_from`, `date_to`, with compact fields for the dashboard. +- `GET /api/jobs/{job_id}/timeline`: returns all 13 nodes with backend-computed display status for each node: `PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `WAITING_USER`, `WAITING_EXTERNAL`, or `SKIPPED`. +- `GET /api/tasks/my`: list tasks by `roleCode` and `status`. For v1, this is a role-filtered dev endpoint, not a user-identity endpoint. +- `POST /api/tasks/{task_id}/submit`: accepts `action`, `payload`, `submitToken`, and `operator`. +- `POST /api/jobs/{job_id}/items`: development/data-ingest endpoint for order details before real `ORDER_FETCH` integration. Request body is `{"replace": true, "rows": [...]}`. Each row maps to `order_item`: `orderNo`, `rawProductName`, `platformProductCode`, `spec`, `manufacturer`, `approvalNo`, `qty`, `unitPrice`, and `sourceRowNo`. Repeated ingest with `replace=true` deletes current rows for that job, inserts the new rows, and updates `order_no_list_json` from the payload. +- `POST /api/dev/match/load-fixture-goods`: development-only endpoint that loads a small in-memory goods fixture so `/api/jobs/{job_id}/match/run` can be demonstrated without Oracle. +- `GET /api/jobs/{job_id}/match/unmatched`: not required by v1 if task rows with `candidates_json` cover the UI; this spec treats the task list as the v1 source of truth and supersedes the older unmatched view for this phase. + +`replace=true` is only valid before `PRODUCT_MATCH` has produced any durable downstream state, or it must transactionally clear dependent `product_match`, `user_task`, and related node/task state for that job in the same operation. The first version should prefer the pre-match restriction. + +Human-node lifecycle rules: +- Human nodes use these roles in v1: + - `SOURCE_ADD` -> `PURCHASER` + - `M5_MANUAL_CONFIRM` -> `PURCHASER` + - `M5_RECEIPT_NOTICE` -> `PURCHASER` +- Entering `SOURCE_ADD`, `M5_MANUAL_CONFIRM`, or `M5_RECEIPT_NOTICE` creates an open `user_task`, inserts a `job_node` row for the node if one does not exist yet, sets `job.status='WAITING_USER'`, and sets `job.current_role` to the matching role. +- Leaving a human node happens through `POST /api/tasks/{task_id}/submit` with `CONFIRM_AND_CONTINUE`, not through the RPA callback path. +- `SOURCE_ADD` completion clears `job.needs_source_add` and advances to the next node. +- `M5_MANUAL_CONFIRM` completion advances to `M5_RECEIPT_NOTICE`. +- `M5_RECEIPT_NOTICE` completion requires `payload.receiptNoticeNo`, writes `job.receipt_notice_no`, and advances to `ARCHIVE_JOB`. + +Task submission rules: +- `SELECT_CANDIDATE` payload must include `productMatchId` and `goodsId`; it may include `goodsCode`, `baseUnit`, and `saveAsAlias`. +- `SELECT_CANDIDATE` delegates to the existing `match_confirm` behavior by mapping `productMatchId` to the existing `match_id` path and passing through `goodsId`, `goodsCode`, `baseUnit`, and `saveAsAlias`. +- `CONFIRM_AND_CONTINUE` can complete manual nodes such as `SOURCE_ADD`, `M5_MANUAL_CONFIRM`, and `M5_RECEIPT_NOTICE` when the task is open and the job is on that node. +- `RETRY_CURRENT_NODE`, `TRANSFER_MANUAL`, and `TERMINATE_JOB` are dev-mode task actions only in v1 and must write an event plus task submission record; they do not invent new business routing. +- `TERMINATE_JOB` sets the job to `CANCELLED` with an event. +- `TRANSFER_MANUAL` changes `current_role` on the job and records the requested target role; full reassignment is later. +- `RETRY_CURRENT_NODE` leaves the job on the same node and records the retry request; it does not delete or overwrite `job_node` history. +- `submitToken` is required. +- First submit locks the task row, stores token/action/payload/result, marks the task `SUBMITTED`, and writes the event. +- Repeat submit with the same token returns the stored result. +- Repeat submit with a different token returns HTTP 409. +- `submitted_result_json` is added to `user_task` to hold the replayable response body. + +Authentication in this first version uses the existing `X-Agent-Token` for write/action endpoints so the demo remains consistent with current code. Read-only endpoints can stay open for local development unless project authentication is added. +For browser-based business submission in v1, the app is local-dev only and exposes a small operator settings panel where the developer pastes the token once per session. The token stays in browser session state and is never bundled into app code. The dev simulator can use the token directly; ordinary business screens must not hardcode it. + +The browser UI must not embed the agent token in normal production flows. RPA callback simulators are allowed only behind an explicit dev flag and must be visually separated from business user actions. + +## Frontend Design + +Create `state_center_web/` with React, TypeScript, Vite, and plain CSS. The UI is an operational dashboard, not a marketing page. + +Views: +- Job list: dense table with job id, supplier, order date, source, status, current node, role, and timestamps. +- Job detail: state-machine diagram across the 13 nodes, plus side panel for job summary, node attempts, open tasks, and allowed actions. +- Tasks: filterable open/submitted task list with role/status controls and submit forms. +- Dev actions: a clearly labeled simulator section for local callback testing only. + +Core frontend files: +- `src/api/client.ts`: fetch wrapper and API types. +- `src/stateMachine.ts`: visual labels and derived status helpers only; no next-node business logic. +- `src/App.tsx`: route/view state and page composition. +- `src/components/JobTimeline.tsx`: node diagram. +- `src/components/JobList.tsx`: job table. +- `src/components/TaskList.tsx`: task table/actions. +- `src/styles.css`: responsive operational layout. + +## Data Flow + +1. User opens the browser app. +2. Frontend loads `/api/state-machine` and `/api/jobs`. +3. User selects a job. +4. Frontend loads `/api/jobs/{job_id}` and `/api/jobs/{job_id}/timeline` and renders node state from backend-computed data. +5. User submits a task/action. +6. Backend updates `user_task`, `job`, `job_node`, and `job_event` as needed. +7. Frontend reloads job/task data. + +## Error Handling + +The frontend should show backend `detail` messages directly in a compact error area. Conflicts such as out-of-order callbacks remain HTTP 409. Missing order items before product match remains HTTP 400. Missing token remains HTTP 401. + +## Testing + +Backend tests: +- State machine metadata includes all 13 nodes and conditional nodes. +- Job list returns compact rows. +- Task list filters by role/status. +- Task submit is idempotent by `task_id + submit_token`. +- Manual node submit rejects jobs not currently on that node. +- Timeline response returns all 13 nodes with `SKIPPED` computed server-side. +- Dev action simulator remains disabled unless the dev flag is enabled. + +Frontend tests: +- Timeline marks completed, current, waiting, failed, and skipped nodes from API data. +- Task list renders open tasks and calls submit with token/action. + +Manual verification: +- Start FastAPI on port 8000. +- Start Vite on port 5173. +- Create a job, ingest fixture order items, simulate callbacks, open job detail, and verify the diagram follows backend state. +- In a local demo build, exercise the dev-only callback simulator without leaking the token into normal business screens. diff --git a/rpa_mock/mock_orders.py b/rpa_mock/mock_orders.py new file mode 100644 index 0000000..79da8d2 --- /dev/null +++ b/rpa_mock/mock_orders.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Mock 医药城/小药药订单数据生成器 +用于在没有真实医药平台访问权限时,模拟 ORDER_FETCH 节点的输出 +""" +import pandas as pd +from datetime import datetime, timedelta +import random +import os + +# 假订单数据模板 +MOCK_SUPPLIERS = [ + "重庆亿昊药业", + "华润医药", + "国药控股", + "上海医药" +] + +MOCK_PRODUCTS = [ + {"name": "阿卡波糖片", "spec": "50mg*30片", "manufacturer": "示例制药", "approval_no": "国药准字H00000001"}, + {"name": "二甲双胍片", "spec": "0.5g*60片", "manufacturer": "示例制药", "approval_no": "国药准字H00000002"}, + {"name": "格列美脲片", "spec": "2mg*10片", "manufacturer": "示例制药", "approval_no": "国药准字H00000003"}, + {"name": "阿托伐他汀钙片", "spec": "10mg*7片", "manufacturer": "示例制药", "approval_no": "国药准字H00000004"}, +] + +def generate_mock_orders(supplier_name: str, order_date: str, source_type: str = "YYC", item_count: int = 5): + """ + 生成模拟订单Excel + + Args: + supplier_name: 供应商名称 + order_date: 订单日期 YYYY-MM-DD + source_type: 来源类型 YYC/XYY/OFFLINE + item_count: 订单明细行数 + + Returns: + 生成的Excel文件路径 + """ + rows = [] + for i in range(item_count): + product = random.choice(MOCK_PRODUCTS) + rows.append({ + "订单号": f"DD-{order_date.replace('-', '')}-{i+1:03d}", + "订单日期": order_date, + "供应商名称": supplier_name, + "药品通用名": product["name"], + "规格": product["spec"], + "单位": "盒", + "数量": random.randint(10, 500), + "单价": round(random.uniform(5.0, 200.0), 2), + "生产厂家": product["manufacturer"], + "批准文号": product["approval_no"], + }) + + df = pd.DataFrame(rows) + + # 生成文件路径 + output_dir = "/tmp/mock_rpa_data" + os.makedirs(output_dir, exist_ok=True) + + filename = f"orders_{supplier_name}_{order_date}_{source_type}.xlsx" + filepath = os.path.join(output_dir, filename) + + df.to_excel(filepath, index=False, sheet_name="订单明细") + print(f"✓ 生成模拟订单: {filepath}") + print(f" - 供应商: {supplier_name}") + print(f" - 订单日期: {order_date}") + print(f" - 明细行数: {len(rows)}") + + return filepath + + +if __name__ == "__main__": + # 生成几个测试订单 + test_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + + print("生成模拟订单数据...\n") + + # 医药城订单(线上) + generate_mock_orders("重庆亿昊药业", test_date, "YYC", item_count=8) + + # 小药药订单(线上) + generate_mock_orders("华润医药", test_date, "XYY", item_count=5) + + # 线下手工单 + generate_mock_orders("国药控股", test_date, "OFFLINE", item_count=3) + + print("\n✓ 全部完成,文件位于: /tmp/mock_rpa_data/") + diff --git a/rpa_mock/order_fetch_mock.py b/rpa_mock/order_fetch_mock.py new file mode 100644 index 0000000..724e754 --- /dev/null +++ b/rpa_mock/order_fetch_mock.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +ORDER_FETCH 节点 Mock 实现 +模拟从医药城/小药药导出订单,实际返回本地生成的假数据 +""" +import requests +import hashlib +from pathlib import Path +from mock_orders import generate_mock_orders +from datetime import datetime, timedelta + + +STATE_CENTER_URL = "http://localhost:8000" # 状态中心地址 +AGENT_TOKEN = "dev-agent-token-12345" # 开发令牌(和state_center的配置一致) + + +def calculate_file_hash(filepath: str) -> str: + """计算文件SHA256哈希""" + sha256 = hashlib.sha256() + with open(filepath, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b''): + sha256.update(chunk) + return f"sha256:{sha256.hexdigest()}" + + +def mock_order_fetch(job_id: str, order_date: str, source_type: str = "YYC"): + """ + 模拟 ORDER_FETCH 节点执行 + + Args: + job_id: 单据ID + order_date: 订单日期 + source_type: 来源类型 YYC/XYY/OFFLINE + """ + print(f"\n{'='*60}") + print(f"🤖 [Mock RPA] ORDER_FETCH 开始执行") + print(f" Job ID: {job_id}") + print(f" 订单日期: {order_date}") + print(f" 来源: {source_type}") + print(f"{'='*60}\n") + + # 1. 调用 start 回调 + print("📞 调用状态中心 /start 回调...") + start_resp = requests.post( + f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/start", + headers={"X-Agent-Token": AGENT_TOKEN}, + json={ + "attemptNo": 1, + "executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}", + "executorPlatform": "mock-rpa", + "operator": "mock-script" + } + ) + print(f" 响应: {start_resp.status_code} - {start_resp.json()}\n") + + # 2. 生成假订单数据 + print("📦 生成模拟订单数据...") + supplier_name = "重庆亿昊药业" if source_type == "YYC" else "华润医药" + excel_path = generate_mock_orders(supplier_name, order_date, source_type, item_count=8) + print() + + # 3. 计算文件哈希 + file_hash = calculate_file_hash(excel_path) + + # 4. 调用 complete 回调 + print("📞 调用状态中心 /complete 回调...") + order_nos = [f"DD-{order_date.replace('-', '')}-{i+1:03d}" for i in range(8)] + + complete_resp = requests.post( + f"{STATE_CENTER_URL}/api/jobs/{job_id}/nodes/ORDER_FETCH/complete", + headers={"X-Agent-Token": AGENT_TOKEN}, + json={ + "attemptNo": 1, + "executorRunId": f"MOCK-RUN-{datetime.now().strftime('%Y%m%d%H%M%S')}", + "executorPlatform": "mock-rpa", + "operator": "mock-script", + "summary": { + "orderCount": 8, + "supplierList": [supplier_name], + "orderNoList": order_nos + }, + "artifacts": [ + { + "artifactType": "SOURCE_EXCEL", + "storagePath": excel_path, + "fileHash": file_hash + } + ] + } + ) + print(f" 响应: {complete_resp.status_code}") + result = complete_resp.json() + print(f" next_node: {result.get('next_node')}") + print(f" job_status: {result.get('job_status')}\n") + + print(f"✅ ORDER_FETCH 完成!下一节点: {result.get('next_node')}\n") + + return result + + +if __name__ == "__main__": + # 示例:模拟昨天的医药城订单 + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + job_id = f"YYC-重庆亿昊药业-{yesterday.replace('-', '')}-001" + + mock_order_fetch(job_id, yesterday, "YYC") diff --git a/rpa_yisaiqi/README.md b/rpa_yisaiqi/README.md new file mode 100644 index 0000000..ba091d4 --- /dev/null +++ b/rpa_yisaiqi/README.md @@ -0,0 +1,147 @@ +# 艺赛旗 CONTRACT_CREATE 联调骨架 + +这个目录放艺赛旗侧可复制到 Windows 虚拟机的代码。第一版目标是先验证: + +1. Windows 虚拟机能访问状态中心。 +2. `CONTRACT_CREATE` 能按约定调用 `start`。 +3. 合同系统动作完成后能调用 `complete`,并带 `businessRefs.contractSystemNo`。 +4. 登录失败、页面改版、合同禁用等异常能调用 `fail`。 + +## 合同节点步骤点 + +来自 `state_center/给艺赛旗的开发交接.md`: + +1. 打开合同测试系统:`http://contract-test.gzmpc.com:8080/` +2. 登录:`test002 / 1` +3. 左侧菜单进入 `合同管理 > 合同起草` +4. 在最近使用模板中选择 `templateName`,非格式默认 `非格式--采购协议` +5. 填签约方信息:客户编码、客户名称 +6. 填基础信息:合同版本类型、合同类别、结算类型、合同背景概述、合同性质 +7. 填金额和日期,合同签订日期和合同开始日期必须等于订单日期 +8. 填货品明细 +9. 填付款信息与特殊标志:是否重大合同、是否预付款、归属年份、付款方式、付款期限、合同状态、是否包含血液制品 +10. 医药城 `YYC`:同供应商同日 PDF 先合并成一个再上传 +11. 小药药 `XYY`:格式合同,跳过合同证文上传 +12. 提交并读取合同编号 +13. 调状态中心 `complete`,必传 `businessRefs.contractSystemNo` + +## 先做联通验证 + +在项目根目录执行: + +```powershell +python -m rpa_yisaiqi.contract_create_node ` + --state-center-url http://127.0.0.1:8000 ` + --token dev-agent-token ` + --job-id YYC-联调测试供应商-20260811-a6e5df ` + --supplier-name 联调测试供应商 ` + --order-date 2026-08-11 ` + --source-type YYC ` + --contract-system-no HT-20260811-001 +``` + +这条只验证回调链路,合同号由命令行传入。 + +## 真实合同起草运行 + +先准备合同起草输入 JSON,参考: + +- `examples/contract_draft_yyc.json` +- `examples/contract_draft_xyy.json` + +YYC 有多份平台 PDF 时需要合并,先安装: + +```powershell +pip install -r rpa_yisaiqi/requirements.txt +``` + +然后运行: + +```powershell +python -m rpa_yisaiqi.contract_create_node ` + --state-center-url http://192.168.x.x:8000 ` + --token dev-agent-token ` + --job-id <状态中心job_id> ` + --supplier-name 重庆亿昊药业 ` + --order-date 2026-08-11 ` + --source-type YYC ` + --draft-path rpa_yisaiqi/examples/contract_draft_yyc.json ` + --merged-pdf-output C:/rpa/jobs/<状态中心job_id>/contract-merged.pdf +``` + +这条会走真实起草流程: + +1. 状态中心 `/start` +2. 打开合同系统并登录 +3. 进入 `合同管理 > 合同起草` +4. 选择最近使用模板 +5. 填签约方、基础信息、金额日期、货品明细、付款和特殊标志 +6. YYC 合并并上传 PDF,XYY 跳过上传 +7. 提交 +8. 读取合同编号 +9. 状态中心 `/complete` + +### 先做 dry-run 预览 + +如果你只想先验证页面,不想真的提交,把输入里的 `submitMode` 设成 `dry_run`。这时流程会跑到提交前并输出 `contract-dry-run-ready` 截图,不会点提交,也不会读取真实合同号。 + +适合先校准 selector 的情况: + +1. 进入 `合同管理 > 合同起草` +2. 选择最近使用模板 +3. 填签约方、基础信息、金额日期、明细、付款和特殊标志 +4. 停在提交前 + +如果状态中心部署在 macOS 主机,`--state-center-url` 换成 Windows 虚拟机能访问到的地址,例如: + +```powershell +--state-center-url http://192.168.x.x:8000 +``` + +## 接入真实页面自动化 + +真实艺赛旗页面操作按流程图拆在: + +- `flows/contract_create_flow.json`:流程图节点清单 +- `contract_flow_steps.py`:每个流程图节点对应的动作函数 +- `yisaiqi_adapter_template.py`:艺赛旗浏览器活动适配模板 + +艺赛旗设计器里建议按这个顺序拖流程图: + +1. `S01_CALLBACK_START`:HTTP 请求状态中心 `/start` +2. `S02_OPEN_CONTRACT_SYSTEM`:打开合同系统 +3. `S03_LOGIN`:输入 `j_username`、`j_password`,点击 `btn_submit` +4. `S04_ENTER_CONTRACT_DRAFT`:进入 `合同管理 > 合同起草` +5. `S05_SELECT_RECENT_TEMPLATE`:选择最近使用模板 +6. `S06_FILL_SIGNATORY_INFO`:填客户编码、客户名称 +7. `S07_FILL_CONTRACT_BASIC_FORM`:填合同类别、结算类型、背景概述、合同性质 +8. `S08_FILL_AMOUNT_AND_DATES`:填金额、签订日期、开始日期、结束日期 +9. `S09_FILL_GOODS_LINES`:循环填货品明细 +10. `S10_FILL_PAYMENT_AND_FLAGS`:填付款方式、付款期限、合同状态、是否包含血液制品等 +11. `S11_MERGE_PDF_IF_YYC`:`sourceType=YYC` 时先合并同供应商同日 PDF;`XYY` 跳过 +12. `S12_UPLOAD_PDF_IF_YYC`:`sourceType=YYC` 时上传合并 PDF;`XYY` 跳过 +13. `S13_SUBMIT`:提交 +14. `S14_READ_CONTRACT_NO`:读取合同编号到变量 `contractSystemNo` +15. `S15_CALLBACK_COMPLETE`:HTTP 请求状态中心 `/complete` + +如果只做预览,把这条流程里的 `submitMode` 设成 `dry_run`,或者直接导入 `flows/contract_create_preview_flow.json`。 + +如果艺赛旗支持 Python 脚本块,直接把每个 `contract_flow_steps.py` 函数挂到对应流程图节点。 +如果艺赛旗只支持原生活动,就按 `flows/contract_create_flow.json` 的节点顺序拖活动,用 `yisaiqi_adapter_template.py` 里的方法名做映射。 + +外层回调骨架仍然在 `contract_create_node.py`: + +```python +create_contract_in_contract_system(spec) +``` + +这个函数最后返回合同编号字符串即可。状态中心回调、幂等键、错误码映射已经封在外层。后续接真页面时,可以在这里调用艺赛旗流程图,或让流程图最后把合同编号回传给这个函数。 + +## 异常映射 + +| 场景 | 异常类 | 回调错误码 | retryable | routeRole | +| --- | --- | --- | --- | --- | +| 登录失败 | `LoginFailed` | `SYS_LOGIN_FAIL` | true | OPS | +| 供应商合同被禁/失效 | `SupplierContractDisabled` | `BIZ_CONTRACT_DISABLED` | false | PURCHASER | +| PDF 合并能力不支持 | `PdfMergeUnsupported` | `CAP_PDF_MERGE_UNSUPPORTED` | false | OPS | +| 页面改版/元素找不到 | `ContractCreateError` | `UI_ELEMENT_NOT_FOUND` | false | OPS | diff --git a/rpa_yisaiqi/__init__.py b/rpa_yisaiqi/__init__.py new file mode 100644 index 0000000..a13d19b --- /dev/null +++ b/rpa_yisaiqi/__init__.py @@ -0,0 +1,2 @@ +"""YiSaiQi RPA integration helpers.""" + diff --git a/rpa_yisaiqi/callback_client.py b/rpa_yisaiqi/callback_client.py new file mode 100644 index 0000000..decff95 --- /dev/null +++ b/rpa_yisaiqi/callback_client.py @@ -0,0 +1,93 @@ +"""HTTP client for YiSaiQi callbacks into the state center.""" +from __future__ import annotations + +import json +from typing import Any, Callable +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + +JsonDict = dict[str, Any] +Opener = Callable[[Request, float], Any] + + +class StateCenterError(RuntimeError): + def __init__(self, status: int, detail: Any): + super().__init__(f"state center returned {status}: {detail}") + self.status = status + self.detail = detail + + +class StateCenterClient: + def __init__( + self, + base_url: str, + token: str, + *, + timeout: float = 15, + opener: Opener | None = None, + ): + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout = timeout + self._opener = opener or _urlopen_with_timeout + + def start_node( + self, + job_id: str, + node_code: str, + *, + attempt_no: int = 1, + operator: str = "agent", + ) -> JsonDict: + return self._post( + f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/start", + { + "attemptNo": attempt_no, + "executorPlatform": "iS-RPA", + "operator": operator, + }, + ) + + def complete_node( + self, + job_id: str, + node_code: str, + payload: JsonDict, + ) -> JsonDict: + return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/complete", payload) + + def fail_node( + self, + job_id: str, + node_code: str, + payload: JsonDict, + ) -> JsonDict: + return self._post(f"/api/jobs/{_path_part(job_id)}/nodes/{_path_part(node_code)}/fail", payload) + + def _post(self, path: str, payload: JsonDict) -> JsonDict: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = Request( + f"{self.base_url}{path}", + data=data, + headers={ + "Content-Type": "application/json", + "X-Agent-Token": self.token, + }, + method="POST", + ) + try: + with self._opener(request, self.timeout) as response: + raw = response.read().decode("utf-8") + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise StateCenterError(exc.code, detail) from exc + return json.loads(raw) if raw else {} + + +def _path_part(value: str) -> str: + return quote(value, safe="") + + +def _urlopen_with_timeout(request: Request, timeout: float) -> Any: + return urlopen(request, timeout=timeout) diff --git a/rpa_yisaiqi/contract_create_node.py b/rpa_yisaiqi/contract_create_node.py new file mode 100644 index 0000000..9a1e2b9 --- /dev/null +++ b/rpa_yisaiqi/contract_create_node.py @@ -0,0 +1,216 @@ +"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration. + +This first version proves the state-center callback path and leaves the +contract-system page automation behind a single replaceable runner function. +""" +from __future__ import annotations + +import argparse +import os +import uuid +import webbrowser +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from .callback_client import StateCenterClient +from .contract_draft import ContractDraft, load_contract_draft +from .contract_flow_steps import ContractFlowContext, run_contract_create_flow +from .pdf_utils import ensure_merged_pdf +from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter + +NODE_CODE = "CONTRACT_CREATE" +CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" + + +class ContractCreateError(RuntimeError): + error_code = "UI_ELEMENT_NOT_FOUND" + retryable = False + route_role = "OPS" + + +class LoginFailed(ContractCreateError): + error_code = "SYS_LOGIN_FAIL" + retryable = True + route_role = "OPS" + + +class SupplierContractDisabled(ContractCreateError): + error_code = "BIZ_CONTRACT_DISABLED" + retryable = False + route_role = "PURCHASER" + + +class PdfMergeUnsupported(ContractCreateError): + error_code = "CAP_PDF_MERGE_UNSUPPORTED" + retryable = False + route_role = "OPS" + + +@dataclass(frozen=True) +class ContractCreateInput: + job_id: str + supplier_name: str + order_date: str + source_type: str + contract_system_no: str | None = None + attempt_no: int = 1 + contract_url: str = CONTRACT_URL + username: str = "test002" + password: str = "1" + open_browser: bool = False + draft_path: str | None = None + merged_pdf_output: str | None = None + + +ContractRunner = Callable[[ContractCreateInput], str] + + +def execute_contract_create( + spec: ContractCreateInput, + client: StateCenterClient, + *, + contract_runner: ContractRunner | None = None, +) -> dict[str, Any]: + client.start_node(spec.job_id, NODE_CODE, attempt_no=spec.attempt_no) + runner = contract_runner or create_contract_in_contract_system + try: + contract_no = runner(spec) + complete_payload = build_complete_payload(spec, contract_no) + return client.complete_node(spec.job_id, NODE_CODE, complete_payload) + except ContractCreateError as exc: + fail_payload = build_fail_payload(spec, exc) + return client.fail_node(spec.job_id, NODE_CODE, fail_payload) + + +def create_contract_in_contract_system(spec: ContractCreateInput) -> str: + """Draft the contract in the contract system and return contract number.""" + if spec.open_browser: + webbrowser.open(spec.contract_url) + if spec.contract_system_no: + return spec.contract_system_no + if not spec.draft_path: + raise ContractCreateError("draft_path is required for real contract drafting") + draft = load_contract_draft(spec.draft_path) + ctx = build_flow_context(spec, draft) + browser = YiSaiQiBrowserAdapter() + try: + return run_contract_create_flow(browser, ctx) + except NotImplementedError as exc: + raise ContractCreateError( + "YiSaiQiBrowserAdapter still needs to be mapped to YiSaiQi designer activities" + ) from exc + + +def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext: + merged_pdf_path = draft.merged_pdf_path + if draft.source_type == "YYC": + output_path = spec.merged_pdf_output or str( + Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf" + ) + try: + merged_pdf_path = ensure_merged_pdf(draft.platform_pdf_paths, output_path) + except (RuntimeError, ValueError) as exc: + raise PdfMergeUnsupported(str(exc)) from exc + return ContractFlowContext( + contract_url=spec.contract_url, + username=spec.username, + password=spec.password, + supplier_name=draft.supplier_name or spec.supplier_name, + order_date=draft.order_date or spec.order_date, + source_type=draft.source_type or spec.source_type, + template_type=draft.template_type, + template_name=draft.template_name, + customer_code=draft.customer_code, + customer_name=draft.customer_name, + contract_category=draft.contract_category, + settlement_type=draft.settlement_type, + contract_background=draft.contract_background, + contract_nature=draft.contract_nature, + amount_currency=draft.amount_currency, + amount=draft.amount, + contract_end_date=draft.contract_end_date, + is_major_contract=draft.is_major_contract, + is_prepayment=draft.is_prepayment, + belong_year=draft.belong_year, + payment_method=draft.payment_method, + payment_days=draft.payment_days, + contract_status=draft.contract_status, + contains_blood_products=draft.contains_blood_products, + submit_mode=draft.submit_mode, + goods_lines=draft.goods_lines, + merged_pdf_path=merged_pdf_path, + ) + + +def build_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[str, Any]: + return { + "attemptNo": spec.attempt_no, + "executorRunId": f"YSQ-CONTRACT-{uuid.uuid4().hex[:12]}", + "idempotencyKey": f"{spec.job_id}:{NODE_CODE}:{spec.attempt_no}:{contract_no}", + "businessRefs": {"contractSystemNo": contract_no}, + "summary": { + "supplierName": spec.supplier_name, + "contractDate": spec.order_date, + "sourceType": spec.source_type, + "contractUrl": spec.contract_url, + "uploadRule": "merge_pdf_before_upload" if spec.source_type == "YYC" else "skip_contract_upload", + }, + "artifacts": [], + } + + +def build_fail_payload(spec: ContractCreateInput, exc: ContractCreateError) -> dict[str, Any]: + return { + "attemptNo": spec.attempt_no, + "errorCode": exc.error_code, + "errorSummary": str(exc), + "retryable": exc.retryable, + "routeRole": exc.route_role, + "artifacts": [], + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE callback skeleton.") + parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000")) + parser.add_argument("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token")) + parser.add_argument("--job-id", required=True) + parser.add_argument("--supplier-name", required=True) + parser.add_argument("--order-date", required=True) + parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC") + parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO")) + parser.add_argument("--attempt-no", type=int, default=1) + parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL)) + parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002")) + parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1")) + parser.add_argument("--open-browser", action="store_true") + parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH")) + parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + spec = ContractCreateInput( + job_id=args.job_id, + supplier_name=args.supplier_name, + order_date=args.order_date, + source_type=args.source_type, + contract_system_no=args.contract_system_no, + attempt_no=args.attempt_no, + contract_url=args.contract_url, + username=args.username, + password=args.password, + open_browser=args.open_browser, + draft_path=args.draft_path, + merged_pdf_output=args.merged_pdf_output, + ) + client = StateCenterClient(args.state_center_url, args.token) + result = execute_contract_create(spec, client) + print(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/rpa_yisaiqi/contract_draft.py b/rpa_yisaiqi/contract_draft.py new file mode 100644 index 0000000..82c972c --- /dev/null +++ b/rpa_yisaiqi/contract_draft.py @@ -0,0 +1,85 @@ +"""Contract draft input model for CONTRACT_CREATE.""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .contract_flow_steps import ContractGoodsLine + + +@dataclass(frozen=True) +class ContractDraft: + supplier_name: str + order_date: str + source_type: str + template_type: str = "FORMAT" + template_name: str = "" + customer_code: str = "" + customer_name: str = "" + contract_category: str = "" + settlement_type: str = "付款" + contract_background: str = "" + contract_nature: str = "主合同" + amount_currency: str = "人民币" + amount: str = "" + contract_end_date: str = "" + is_major_contract: str = "否" + is_prepayment: str = "否" + belong_year: str = "" + payment_method: str = "账期支付" + payment_days: str = "30" + contract_status: str = "正式合同" + contains_blood_products: str = "否" + submit_mode: str = "submit" + goods_lines: list[ContractGoodsLine] = field(default_factory=list) + platform_pdf_paths: list[str] = field(default_factory=list) + merged_pdf_path: str | None = None + + +def load_contract_draft(path: str | Path) -> ContractDraft: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return contract_draft_from_dict(data) + + +def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft: + goods_lines = [ + ContractGoodsLine( + operation_code=str(item.get("operationCode", "")), + goods_code=str(item.get("goodsCode", "")), + goods_name=str(item.get("goodsName", "")), + approval_no=str(item.get("approvalNo", "")), + spec=str(item.get("spec", "")), + unit=str(item.get("unit", "")), + quantity=float(item.get("quantity", 0)), + ) + for item in data.get("goodsLines", []) + ] + return ContractDraft( + supplier_name=str(data.get("supplierName", "")), + order_date=str(data.get("orderDate", "")), + source_type=str(data.get("sourceType", "")), + template_type=str(data.get("templateType", "FORMAT")), + template_name=str(data.get("templateName", "")), + customer_code=str(data.get("customerCode", "")), + customer_name=str(data.get("customerName", "")), + contract_category=str(data.get("contractCategory", "")), + settlement_type=str(data.get("settlementType", "付款")), + contract_background=str(data.get("contractBackground", "")), + contract_nature=str(data.get("contractNature", "主合同")), + amount_currency=str(data.get("amountCurrency", "人民币")), + amount=str(data.get("amount", "")), + contract_end_date=str(data.get("contractEndDate", "")), + is_major_contract=str(data.get("isMajorContract", "否")), + is_prepayment=str(data.get("isPrepayment", "否")), + belong_year=str(data.get("belongYear", "")), + payment_method=str(data.get("paymentMethod", "账期支付")), + payment_days=str(data.get("paymentDays", "30")), + contract_status=str(data.get("contractStatus", "正式合同")), + contains_blood_products=str(data.get("containsBloodProducts", "否")), + submit_mode=str(data.get("submitMode", "submit")), + goods_lines=goods_lines, + platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])], + merged_pdf_path=data.get("mergedPdfPath"), + ) diff --git a/rpa_yisaiqi/contract_flow_steps.py b/rpa_yisaiqi/contract_flow_steps.py new file mode 100644 index 0000000..bc0b0cf --- /dev/null +++ b/rpa_yisaiqi/contract_flow_steps.py @@ -0,0 +1,212 @@ +"""Flowchart-sized CONTRACT_CREATE browser steps for YiSaiQi. + +Each function is intentionally small enough to map to one YiSaiQi flowchart +node or one script block behind a flowchart node. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + + +class BrowserAdapter(Protocol): + def open_url(self, url: str) -> None: ... + def fill_first(self, selectors: list[str], value: str) -> None: ... + def click_first(self, selectors: list[str]) -> None: ... + def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ... + def select_first(self, selectors: list[str], value: str) -> None: ... + def upload_file_first(self, selectors: list[str], file_path: str) -> None: ... + def read_first(self, selectors: list[str], value_name: str) -> str: ... + def screenshot(self, name: str) -> str: ... + + +@dataclass(frozen=True) +class ContractGoodsLine: + operation_code: str + goods_code: str + goods_name: str + approval_no: str + spec: str + unit: str + quantity: float + + +@dataclass(frozen=True) +class ContractFlowContext: + contract_url: str + username: str + password: str + supplier_name: str + order_date: str + source_type: str + template_type: str = "FORMAT" + template_name: str = "" + customer_code: str = "" + customer_name: str = "" + contract_category: str = "" + settlement_type: str = "付款" + contract_background: str = "" + contract_nature: str = "主合同" + amount_currency: str = "人民币" + amount: str = "" + contract_end_date: str = "" + is_major_contract: str = "否" + is_prepayment: str = "否" + belong_year: str = "" + payment_method: str = "账期支付" + payment_days: str = "30" + contract_status: str = "正式合同" + contains_blood_products: str = "否" + submit_mode: str = "submit" + goods_lines: list[ContractGoodsLine] = field(default_factory=list) + merged_pdf_path: str | None = None + + +def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str: + open_contract_system(browser, ctx) + login_contract_system(browser, ctx) + enter_contract_create(browser) + select_contract_template(browser, ctx) + fill_contract_signatory_info(browser, ctx) + fill_contract_basic_info(browser, ctx) + fill_contract_amount_and_dates(browser, ctx) + fill_contract_goods_lines(browser, ctx) + fill_contract_payment_info(browser, ctx) + upload_contract_document_if_needed(browser, ctx) + if not submit_contract_for_approval(browser, ctx): + return "" + return read_contract_number(browser) + + +def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + browser.open_url(ctx.contract_url) + + +def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username) + browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password) + browser.click_first(['input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录']) + browser.wait_for_text("合同", timeout_seconds=20) + + +def enter_contract_create(browser: BrowserAdapter) -> None: + browser.click_first(["text=合同管理", "aria=合同管理"]) + browser.click_first(["text=合同起草", "aria=合同起草"]) + browser.wait_for_text("最近使用模板", timeout_seconds=20) + + +def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + template_text = ctx.template_name or ( + "非格式--采购协议" if ctx.template_type == "NON_FORMAT" else "购销协议书(2025)" + ) + browser.click_first([f"text={template_text}", f"text={template_text}...", f"title={template_text}"]) + browser.wait_for_text("合同起草", timeout_seconds=20) + + +def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + customer_name = ctx.customer_name or ctx.supplier_name + if ctx.customer_code: + browser.fill_first( + ['label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'], + ctx.customer_code, + ) + if customer_name: + browser.fill_first( + ['label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'], + customer_name, + ) + browser.screenshot("contract-signatory-info-filled") + + +def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + browser.select_first(['label=合同版本类型', 'select[name="fdTemplateType"]'], contract_version_label(ctx)) + if ctx.contract_category: + browser.select_first(['label=合同类别', 'select[name="fdContractCategory"]'], ctx.contract_category) + browser.select_first(['label=结算类型', 'select[name="fdSettlementType"]'], ctx.settlement_type) + if ctx.contract_background: + browser.fill_first( + ['label=合同背景概述', 'textarea[name="fdContractBackground"]'], + ctx.contract_background, + ) + browser.click_first([f"label={ctx.contract_nature}", f"text={ctx.contract_nature}"]) + browser.screenshot("contract-basic-info-filled") + + +def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + browser.select_first(['label=合同金额', 'select[name="fdCurrency"]'], ctx.amount_currency) + if ctx.amount: + browser.fill_first(['label=金额(元)', 'input[name="fdAmount"]'], ctx.amount) + browser.fill_first( + ['label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'], + ctx.order_date, + ) + browser.fill_first( + ['label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'], + ctx.order_date, + ) + if ctx.contract_end_date: + browser.fill_first( + ['label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'], + ctx.contract_end_date, + ) + browser.screenshot("contract-amount-and-dates-filled") + + +def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + for index, line in enumerate(ctx.goods_lines, start=1): + if index > 1: + browser.click_first(["text=新增明细", "text=添加货品", "text=新增行"]) + prefix = f"明细{index}" + browser.fill_first([f'label={prefix}操作码', 'input[name="operationCode"]'], line.operation_code) + browser.fill_first([f'label={prefix}货品编码', 'input[name="goodsCode"]'], line.goods_code) + browser.fill_first([f'label={prefix}名称', 'input[name="goodsName"]'], line.goods_name) + browser.fill_first([f'label={prefix}国药准字', 'input[name="approvalNo"]'], line.approval_no) + browser.fill_first([f'label={prefix}规格', 'input[name="spec"]'], line.spec) + browser.fill_first([f'label={prefix}单位', 'input[name="unit"]'], line.unit) + browser.fill_first([f'label={prefix}数量', 'input[name="quantity"]'], str(line.quantity)) + browser.screenshot("contract-goods-lines-filled") + + +def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + browser.select_first(['label=是否重大合同', 'select[name="fdIsMajorContract"]'], ctx.is_major_contract) + browser.select_first(['label=是否预付款', 'select[name="fdIsPrepayment"]'], ctx.is_prepayment) + if ctx.belong_year: + browser.fill_first(['label=合同归属年份', 'input[name="fdBelongYear"]'], ctx.belong_year) + browser.select_first(['label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'], ctx.payment_method) + browser.fill_first(['label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'], ctx.payment_days) + browser.select_first(['label=合同状态', 'select[name="fdContractStatus"]'], ctx.contract_status) + browser.select_first( + ['label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'], + ctx.contains_blood_products, + ) + browser.screenshot("contract-payment-filled") + + +def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + if ctx.source_type != "YYC": + return + if not ctx.merged_pdf_path: + raise ValueError("YYC contract creation requires merged_pdf_path before upload") + browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path) + browser.screenshot("contract-pdf-uploaded") + + +def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowContext) -> bool: + if ctx.submit_mode == "dry_run": + browser.screenshot("contract-dry-run-ready") + return False + browser.click_first(["text=提交", "text=提交送审", "text=送审"]) + browser.wait_for_text("合同编号", timeout_seconds=30) + browser.screenshot("contract-submitted") + return True + + +def read_contract_number(browser: BrowserAdapter) -> str: + return browser.read_first( + ['input[name="contractNo"]', 'input[name="contractSystemNo"]', "label=合同编号", "text=合同编号"], + "contract_number", + ) + + +def contract_version_label(ctx: ContractFlowContext) -> str: + return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同" diff --git a/rpa_yisaiqi/examples/contract_draft_xyy.json b/rpa_yisaiqi/examples/contract_draft_xyy.json new file mode 100644 index 0000000..bfc3103 --- /dev/null +++ b/rpa_yisaiqi/examples/contract_draft_xyy.json @@ -0,0 +1,35 @@ +{ + "supplierName": "小药药供应商", + "orderDate": "2026-08-11", + "sourceType": "XYY", + "templateType": "FORMAT", + "templateName": "购销协议书(2025)", + "customerCode": "CUST-XYY-001", + "customerName": "小药药供应商", + "contractCategory": "采购协议", + "settlementType": "付款", + "contractBackground": "按小药药订单自动起草采购协议", + "contractNature": "主合同", + "amountCurrency": "人民币", + "amount": "9600.00", + "contractEndDate": "2026-12-31", + "isMajorContract": "否", + "isPrepayment": "否", + "belongYear": "2026", + "paymentMethod": "账期支付", + "paymentDays": "30", + "contractStatus": "正式合同", + "containsBloodProducts": "否", + "platformPdfPaths": [], + "goodsLines": [ + { + "operationCode": "ADD", + "goodsCode": "YP-002", + "goodsName": "哈药牌钙铁锌口服液", + "approvalNo": "国药准字B00000002", + "spec": "10ml*12支", + "unit": "盒", + "quantity": 24 + } + ] +} diff --git a/rpa_yisaiqi/examples/contract_draft_yyc.json b/rpa_yisaiqi/examples/contract_draft_yyc.json new file mode 100644 index 0000000..72a6262 --- /dev/null +++ b/rpa_yisaiqi/examples/contract_draft_yyc.json @@ -0,0 +1,38 @@ +{ + "supplierName": "重庆亿昊药业", + "orderDate": "2026-08-11", + "sourceType": "YYC", + "templateType": "NON_FORMAT", + "templateName": "非格式--采购协议", + "customerCode": "CUST-YYC-001", + "customerName": "重庆亿昊药业", + "contractCategory": "采购协议", + "settlementType": "付款", + "contractBackground": "按医药城订单自动起草采购协议", + "contractNature": "主合同", + "amountCurrency": "人民币", + "amount": "12000.50", + "contractEndDate": "2026-12-31", + "isMajorContract": "否", + "isPrepayment": "否", + "belongYear": "2026", + "paymentMethod": "账期支付", + "paymentDays": "30", + "contractStatus": "正式合同", + "containsBloodProducts": "否", + "platformPdfPaths": [ + "C:/rpa/jobs/JOB-1/platform-contract-1.pdf", + "C:/rpa/jobs/JOB-1/platform-contract-2.pdf" + ], + "goodsLines": [ + { + "operationCode": "ADD", + "goodsCode": "YP-001", + "goodsName": "阿卡波糖片", + "approvalNo": "国药准字H00000001", + "spec": "50mg*30片", + "unit": "盒", + "quantity": 12 + } + ] +} diff --git a/rpa_yisaiqi/flows/contract_create_flow.json b/rpa_yisaiqi/flows/contract_create_flow.json new file mode 100644 index 0000000..d3fa6be --- /dev/null +++ b/rpa_yisaiqi/flows/contract_create_flow.json @@ -0,0 +1,151 @@ +{ + "flowName": "CONTRACT_CREATE_合同建单", + "platform": "YiSaiQi iS-RPA", + "nodeCode": "CONTRACT_CREATE", + "inputs": [ + "stateCenterUrl", + "agentToken", + "jobId", + "supplierName", + "orderDate", + "sourceType", + "templateType", + "templateName", + "customerCode", + "customerName", + "contractCategory", + "settlementType", + "contractBackground", + "amount", + "contractEndDate", + "paymentMethod", + "paymentDays", + "submitMode", + "goodsLines", + "mergedPdfPath" + ], + "outputs": [ + "contractSystemNo", + "nextNode" + ], + "steps": [ + { + "id": "S01_CALLBACK_START", + "name": "回调状态中心-start", + "script": "StateCenterClient.start_node(jobId, 'CONTRACT_CREATE')" + }, + { + "id": "S02_OPEN_CONTRACT_SYSTEM", + "name": "打开合同系统", + "script": "open_contract_system(browser, ctx)" + }, + { + "id": "S03_LOGIN", + "name": "登录合同系统", + "script": "login_contract_system(browser, ctx)", + "selectors": { + "username": "input[name='j_username']", + "password": "input[name='j_password']", + "submit": "input[name='btn_submit']" + } + }, + { + "id": "S04_ENTER_CONTRACT_DRAFT", + "name": "进入合同起草", + "script": "enter_contract_create(browser)" + }, + { + "id": "S05_SELECT_RECENT_TEMPLATE", + "name": "选择最近使用模板", + "script": "select_contract_template(browser, ctx)", + "rule": "优先点击最近使用模板中的 templateName;非格式默认非格式--采购协议" + }, + { + "id": "S06_FILL_SIGNATORY_INFO", + "name": "填写签约方信息", + "script": "fill_contract_signatory_info(browser, ctx)", + "fields": [ + "客户编码", + "客户名称" + ] + }, + { + "id": "S07_FILL_CONTRACT_BASIC_FORM", + "name": "填写合同基础表单", + "script": "fill_contract_basic_info(browser, ctx)", + "fields": [ + "合同版本类型", + "合同类别", + "结算类型", + "合同背景概述", + "合同性质" + ] + }, + { + "id": "S08_FILL_AMOUNT_AND_DATES", + "name": "填写金额与日期", + "script": "fill_contract_amount_and_dates(browser, ctx)", + "hardRules": [ + "合同签订日期=orderDate", + "合同开始日期=orderDate" + ] + }, + { + "id": "S09_FILL_GOODS_LINES", + "name": "填写货品明细", + "script": "fill_contract_goods_lines(browser, ctx)" + }, + { + "id": "S10_FILL_PAYMENT_AND_FLAGS", + "name": "填写付款信息与特殊标志", + "script": "fill_contract_payment_info(browser, ctx)" + }, + { + "id": "S11_MERGE_PDF_IF_YYC", + "name": "医药城合并同供应商同日PDF", + "script": "ensure_merged_pdf(platformPdfPaths, mergedPdfOutput)", + "branch": "sourceType=YYC 且 platformPdfPaths 多于一份时执行;单份直接沿用;sourceType=XYY 跳过" + }, + { + "id": "S12_UPLOAD_PDF_IF_YYC", + "name": "医药城上传合并合同PDF", + "script": "upload_contract_document_if_needed(browser, ctx)", + "branch": "sourceType=YYC 才执行;sourceType=XYY 跳过" + }, + { + "id": "S13_SUBMIT", + "name": "提交", + "script": "submit_contract_for_approval(browser)" + }, + { + "id": "S14_READ_CONTRACT_NO", + "name": "读取合同编号", + "script": "read_contract_number(browser)" + }, + { + "id": "S15_CALLBACK_COMPLETE", + "name": "回调状态中心-complete", + "script": "StateCenterClient.complete_node(jobId, 'CONTRACT_CREATE', businessRefs.contractSystemNo)" + }, + { + "id": "E_LOGIN_FAILED", + "name": "异常-登录失败", + "script": "StateCenterClient.fail_node(..., errorCode='SYS_LOGIN_FAIL', retryable=true, routeRole='OPS')" + }, + { + "id": "E_CONTRACT_DISABLED", + "name": "异常-供应商合同禁用", + "script": "StateCenterClient.fail_node(..., errorCode='BIZ_CONTRACT_DISABLED', retryable=false, routeRole='PURCHASER')" + }, + { + "id": "E_PDF_MERGE_UNSUPPORTED", + "name": "异常-PDF合并能力不支持", + "script": "StateCenterClient.fail_node(..., errorCode='CAP_PDF_MERGE_UNSUPPORTED', retryable=false, routeRole='OPS')" + }, + { + "id": "E_PAGE_CHANGED", + "name": "异常-页面改版", + "script": "StateCenterClient.fail_node(..., errorCode='UI_ELEMENT_NOT_FOUND', retryable=false, routeRole='OPS')" + } + ] +} diff --git a/rpa_yisaiqi/flows/contract_create_preview_flow.json b/rpa_yisaiqi/flows/contract_create_preview_flow.json new file mode 100644 index 0000000..72906b3 --- /dev/null +++ b/rpa_yisaiqi/flows/contract_create_preview_flow.json @@ -0,0 +1,42 @@ +{ + "flowName": "CONTRACT_CREATE_预览", + "platform": "YiSaiQi iS-RPA", + "nodeCode": "CONTRACT_CREATE", + "inputs": [ + "stateCenterUrl", + "agentToken", + "jobId", + "supplierName", + "orderDate", + "sourceType", + "templateType", + "templateName", + "customerCode", + "customerName", + "contractCategory", + "settlementType", + "contractBackground", + "amount", + "contractEndDate", + "paymentMethod", + "paymentDays", + "submitMode", + "goodsLines", + "mergedPdfPath" + ], + "outputs": [ + "previewDone" + ], + "steps": [ + { "id": "P01", "name": "打开合同系统", "script": "open_contract_system(browser, ctx)" }, + { "id": "P02", "name": "登录合同系统", "script": "login_contract_system(browser, ctx)" }, + { "id": "P03", "name": "进入合同起草", "script": "enter_contract_create(browser)" }, + { "id": "P04", "name": "选择模板", "script": "select_contract_template(browser, ctx)" }, + { "id": "P05", "name": "填写签约方", "script": "fill_contract_signatory_info(browser, ctx)" }, + { "id": "P06", "name": "填写基础表单", "script": "fill_contract_basic_info(browser, ctx)" }, + { "id": "P07", "name": "填写金额日期", "script": "fill_contract_amount_and_dates(browser, ctx)" }, + { "id": "P08", "name": "填写明细", "script": "fill_contract_goods_lines(browser, ctx)" }, + { "id": "P09", "name": "填写付款与标志", "script": "fill_contract_payment_info(browser, ctx)" }, + { "id": "P10", "name": "预览结束", "script": "submit_contract_for_approval(browser, ctx) with submitMode=dry_run" } + ] +} diff --git a/rpa_yisaiqi/pdf_utils.py b/rpa_yisaiqi/pdf_utils.py new file mode 100644 index 0000000..272a28e --- /dev/null +++ b/rpa_yisaiqi/pdf_utils.py @@ -0,0 +1,32 @@ +"""PDF utilities for YiSaiQi contract drafting.""" +from __future__ import annotations + +from pathlib import Path + + +def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: + """Return one PDF path, merging multiple source PDFs when needed. + + YiSaiQi must upload only one YYC contract document for the same supplier + and same order date. A single source PDF can be uploaded directly. + """ + if not source_paths: + raise ValueError("YYC contract creation requires at least one platform PDF") + if len(source_paths) == 1: + return source_paths[0] + + try: + from pypdf import PdfWriter + except ImportError as exc: + raise RuntimeError("Install pypdf to merge multiple YYC platform PDFs") from exc + + writer = PdfWriter() + for source in source_paths: + writer.append(str(Path(source))) + + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("wb") as file: + writer.write(file) + return str(out) + diff --git a/rpa_yisaiqi/requirements.txt b/rpa_yisaiqi/requirements.txt new file mode 100644 index 0000000..5629a7f --- /dev/null +++ b/rpa_yisaiqi/requirements.txt @@ -0,0 +1 @@ +pypdf==5.1.0 diff --git a/rpa_yisaiqi/selectors/contract_create_selectors.json b/rpa_yisaiqi/selectors/contract_create_selectors.json new file mode 100644 index 0000000..e1c8bb6 --- /dev/null +++ b/rpa_yisaiqi/selectors/contract_create_selectors.json @@ -0,0 +1,116 @@ +{ + "knownLoginSelectors": { + "username": "input[name='j_username']", + "password": "input[name='j_password']", + "submit": "input[name='btn_submit']" + }, + "candidateSelectors": { + "contractManagementMenu": [ + "text=合同管理", + "aria=合同管理" + ], + "contractDraftMenu": [ + "text=合同起草", + "aria=合同起草" + ], + "recentNonFormatPurchaseTemplate": [ + "text=非格式--采购协议", + "text=非格式--采购协议...", + "title=非格式--采购协议" + ], + "customerCode": [ + "label=客户编码", + "input[name='fdCustomerCode']", + "input[placeholder*='客户编码']" + ], + "customerName": [ + "label=客户名称", + "input[name='fdCustomerName']", + "input[placeholder*='客户名称']" + ], + "contractVersionType": [ + "label=合同版本类型", + "select[name='fdTemplateType']" + ], + "contractCategory": [ + "label=合同类别", + "select[name='fdContractCategory']" + ], + "settlementType": [ + "label=结算类型", + "select[name='fdSettlementType']" + ], + "contractBackground": [ + "label=合同背景概述", + "textarea[name='fdContractBackground']" + ], + "contractNatureMain": [ + "label=主合同", + "text=主合同" + ], + "amountCurrency": [ + "label=合同金额", + "select[name='fdCurrency']" + ], + "amount": [ + "label=金额(元)", + "input[name='fdAmount']" + ], + "signDate": [ + "label=合同签订日期", + "input[name='fdSignDate']", + "input[placeholder*='合同签订']" + ], + "beginDate": [ + "label=合同开始日期", + "input[name='fdStartDate']", + "input[placeholder*='合同开始']" + ], + "endDate": [ + "label=合同结束日期", + "input[name='fdEndDate']", + "input[placeholder*='合同结束']" + ], + "isMajorContract": [ + "label=是否重大合同", + "select[name='fdIsMajorContract']" + ], + "isPrepayment": [ + "label=是否预付款", + "select[name='fdIsPrepayment']" + ], + "belongYear": [ + "label=合同归属年份", + "input[name='fdBelongYear']" + ], + "paymentMethod": [ + "label=付款方式", + "select[name='fdPaymentMethod']", + "select[name='paymentMethod']" + ], + "paymentDays": [ + "label=付款期限", + "input[name='fdPaymentDays']", + "input[name='paymentDays']" + ], + "contractStatus": [ + "label=合同状态", + "select[name='fdContractStatus']" + ], + "containsBloodProducts": [ + "label=是否包含血液制品", + "select[name='fdContainsBloodProducts']" + ], + "submit": [ + "text=提交", + "text=提交送审", + "text=送审" + ], + "contractNumber": [ + "input[name='contractNo']", + "input[name='contractSystemNo']", + "label=合同编号", + "text=合同编号" + ] + } +} diff --git a/rpa_yisaiqi/yisaiqi_adapter_template.py b/rpa_yisaiqi/yisaiqi_adapter_template.py new file mode 100644 index 0000000..47f0468 --- /dev/null +++ b/rpa_yisaiqi/yisaiqi_adapter_template.py @@ -0,0 +1,42 @@ +"""YiSaiQi adapter template for CONTRACT_CREATE flowchart nodes. + +This file is a mapping template. In the YiSaiQi designer, replace each method +body with the platform's native browser activities: open webpage, input text, +click element, select dropdown, upload file, read text, and screenshot. +""" +from __future__ import annotations + + +class YiSaiQiBrowserAdapter: + def open_url(self, url: str) -> None: + """Flowchart node: 浏览器打开网页.""" + raise NotImplementedError("Map to YiSaiQi browser open-url activity") + + def fill_first(self, selectors: list[str], value: str) -> None: + """Flowchart node: 按候选定位器依次查找,找到后输入文本.""" + raise NotImplementedError(f"Map to YiSaiQi input-text activity: {selectors=} {value=}") + + def click_first(self, selectors: list[str]) -> None: + """Flowchart node: 按候选定位器依次查找,找到后点击.""" + raise NotImplementedError(f"Map to YiSaiQi click activity: {selectors=}") + + def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: + """Flowchart node: 等待页面出现指定文本.""" + raise NotImplementedError(f"Map to YiSaiQi wait-text activity: {text=} {timeout_seconds=}") + + def select_first(self, selectors: list[str], value: str) -> None: + """Flowchart node: 下拉框选择.""" + raise NotImplementedError(f"Map to YiSaiQi select activity: {selectors=} {value=}") + + def upload_file_first(self, selectors: list[str], file_path: str) -> None: + """Flowchart node: 文件上传.""" + raise NotImplementedError(f"Map to YiSaiQi upload-file activity: {selectors=} {file_path=}") + + def read_first(self, selectors: list[str], value_name: str) -> str: + """Flowchart node: 读取页面文本/输入框值并写入流程变量.""" + raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}") + + def screenshot(self, name: str) -> str: + """Flowchart node: 截图存证,返回文件路径.""" + raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}") + diff --git a/run_mock_chain.py b/run_mock_chain.py new file mode 100644 index 0000000..8855cdd --- /dev/null +++ b/run_mock_chain.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +全链路Mock验证脚本 +不依赖真实医药城/合同系统,用mock数据跑通:建单→ORDER_FETCH→对码→CONTRACT_CREATE +验证状态机推进、合同编号回填、前端能观测到next_node + +用法: + export STATE_CENTER_URL="http://127.0.0.1:8000" + export AGENT_TOKEN="dev-agent-token" + python run_mock_chain.py +""" +import json +import sys +import uuid +from datetime import datetime, timedelta +from urllib.request import Request, urlopen +from urllib.error import HTTPError +from urllib.parse import quote + + +def encode_job_id(job_id: str) -> str: + """job_id含中文,URL路径段需编码""" + return quote(job_id, safe="") + +STATE_CENTER_URL = "http://127.0.0.1:8000" +AGENT_TOKEN = "dev-agent-token" + + +def http_post(path: str, payload: dict, token: str | None = None): + """POST请求辅助函数""" + url = f"{STATE_CENTER_URL}{path}" + headers = {"Content-Type": "application/json"} + if token: + headers["X-Agent-Token"] = token + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = Request(url, data=data, headers=headers, method="POST") + + try: + with urlopen(request, timeout=15) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"❌ HTTP {exc.code}: {detail}") + raise + + +def http_get(path: str): + """GET请求辅助函数""" + url = f"{STATE_CENTER_URL}{path}" + request = Request(url, method="GET") + + try: + with urlopen(request, timeout=15) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"❌ HTTP {exc.code}: {detail}") + raise + + +def step(num: int, title: str): + print(f"\n{'='*60}") + print(f"【第{num}步】{title}") + print('='*60) + + +def run_chain(): + order_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + # supplier加唯一后缀,保证每次全新job(避免幂等复用旧状态) + supplier = f"重庆亿昊药业-{uuid.uuid4().hex[:4]}" + source_type = "YYC" # 医药城线上单 + + # ---- 第1步:建单 ---- + step(1, "建单 POST /api/jobs") + resp = http_post("/api/jobs", { + "source_type": source_type, + "order_date": order_date, + "supplier_name": supplier, + "created_by": "mock-chain", + }) + job_id = resp["job_id"] + print(f"✓ job_id: {job_id}") + print(f"✓ first_node: {resp.get('first_node')}") + print(f"✓ reused: {resp.get('reused')}") + + # ---- 第2步:加载fixture货品(不依赖Oracle)---- + step(2, "加载测试货品 POST /api/dev/match/load-fixture-goods") + resp = http_post("/api/dev/match/load-fixture-goods", {}, token=AGENT_TOKEN) + print(f"✓ 已加载货品数: {resp.get('loaded')}") + + # ---- 第3步:ORDER_FETCH 开始回调 ---- + step(3, "ORDER_FETCH start 回调") + resp = http_post( + f"/api/jobs/{encode_job_id(job_id)}/nodes/ORDER_FETCH/start", + {"attemptNo": 1, "executorPlatform": "mock-rpa", "operator": "mock-chain"}, + token=AGENT_TOKEN, + ) + print(f"✓ start响应: {resp}") + + # ---- 第4步:写订单明细(模拟ORDER_FETCH导出的数据)---- + step(4, "写订单明细 POST /api/jobs/{job_id}/items") + order_no = f"DD-{order_date.replace('-', '')}-001" + resp = http_post( + f"/api/jobs/{encode_job_id(job_id)}/items", + { + "replace": True, + "rows": [ + { + "orderNo": order_no, + "rawProductName": "阿卡波糖片", + "platformProductCode": "YP-001", + "spec": "50mg*30片", + "manufacturer": "示例制药", + "approvalNo": "国药准字H00000001", + "qty": 100, + "unitPrice": 25.5, + "sourceRowNo": 1, + }, + { + "orderNo": order_no, + "rawProductName": "阿莫西林胶囊", + "platformProductCode": "YP-002", + "spec": "0.25g*24粒", + "manufacturer": "示例医药", + "approvalNo": "国药准字H00000002", + "qty": 50, + "unitPrice": 18.0, + "sourceRowNo": 2, + }, + ], + }, + token=AGENT_TOKEN, + ) + print(f"✓ 写入明细: {resp.get('inserted')} 行") + print(f"✓ 订单号: {resp.get('order_nos')}") + + # ---- 第5步:ORDER_FETCH 完成回调 ---- + step(5, "ORDER_FETCH complete 回调") + resp = http_post( + f"/api/jobs/{encode_job_id(job_id)}/nodes/ORDER_FETCH/complete", + { + "attemptNo": 1, + "executorRunId": f"MOCK-{uuid.uuid4().hex[:8]}", + "summary": { + "orderCount": 1, + "supplierList": [supplier], + "orderNoList": [order_no], + }, + "artifacts": [ + { + "artifactType": "SOURCE_EXCEL", + "storagePath": f"/tmp/mock_rpa_data/orders_{supplier}_{order_date}.xlsx", + "fileHash": "sha256:mock-order-excel", + } + ], + }, + token=AGENT_TOKEN, + ) + print(f"✓ next_node: {resp.get('next_node')}") + + # ---- 第6步:跑对码 ---- + step(6, "对码 POST /api/jobs/{job_id}/match/run") + resp = http_post(f"/api/jobs/{encode_job_id(job_id)}/match/run", {}, token=AGENT_TOKEN) + print(f"✓ 对码结果: {json.dumps(resp, ensure_ascii=False)}") + + # ---- 第7步:查当前状态,判断是否需要人工对码 ---- + step(7, "查单据状态 GET /api/jobs/{job_id}") + detail = http_get(f"/api/jobs/{encode_job_id(job_id)}") + job = detail.get("job", {}) + print(f"✓ 当前节点: {job.get('current_node_code')}") + print(f"✓ 当前状态: {job.get('status')}") + + tasks = detail.get("tasks", []) + if tasks: + print(f"⚠ 有 {len(tasks)} 条待办需要人工处理") + for t in tasks: + print(f" - {t.get('title')} [{t.get('reason_type')}]") + + # ---- 第7.5步:如卡在SOURCE_ADD,模拟人工完成新增货源推进 ---- + if job.get("current_node_code") == "SOURCE_ADD": + step("7.5", "SOURCE_ADD complete(模拟人工新增货源完成)") + resp = http_post( + f"/api/jobs/{encode_job_id(job_id)}/nodes/SOURCE_ADD/complete", + { + "attemptNo": 1, + "executorRunId": f"MOCK-SRC-{uuid.uuid4().hex[:8]}", + "operator": "mock-chain", + "summary": {"sourceAdded": True}, + "artifacts": [], + }, + token=AGENT_TOKEN, + ) + print(f"✓ next_node: {resp.get('next_node')}") + + # ---- 第8步:CONTRACT_CREATE mock(直接传假合同编号)---- + step(8, "CONTRACT_CREATE mock(传假合同编号,跳过真实页面)") + mock_contract_no = f"HT{order_date.replace('-', '')}{uuid.uuid4().hex[:5].upper()}" + print(f" 使用mock合同编号: {mock_contract_no}") + + # 先start + http_post( + f"/api/jobs/{encode_job_id(job_id)}/nodes/CONTRACT_CREATE/start", + {"attemptNo": 1, "executorPlatform": "mock-rpa", "operator": "mock-chain"}, + token=AGENT_TOKEN, + ) + # 再complete,带businessRefs.contractSystemNo + resp = http_post( + f"/api/jobs/{encode_job_id(job_id)}/nodes/CONTRACT_CREATE/complete", + { + "attemptNo": 1, + "executorRunId": f"MOCK-CONTRACT-{uuid.uuid4().hex[:8]}", + "businessRefs": {"contractSystemNo": mock_contract_no}, + "summary": { + "supplierName": supplier, + "contractDate": order_date, + "sourceType": source_type, + "uploadRule": "merge_pdf_before_upload", + }, + "artifacts": [], + }, + token=AGENT_TOKEN, + ) + print(f"✓ next_node: {resp.get('next_node')}") + + # ---- 第9步:验证合同编号回填 ---- + step(9, "验证合同编号回填 GET /api/jobs/{job_id}") + detail = http_get(f"/api/jobs/{encode_job_id(job_id)}") + job = detail.get("job", {}) + print(f"✓ 当前节点: {job.get('current_node_code')}") + print(f"✓ 当前状态: {job.get('status')}") + contract_no = job.get("contract_system_no") + if contract_no == mock_contract_no: + print(f"✅ 合同编号回填成功: {contract_no}") + else: + print(f"⚠ 合同编号: {contract_no}(期望 {mock_contract_no})") + + print(f"\n{'='*60}") + print(f"🎉 Mock链跑完!job_id = {job_id}") + print(f" 前端观测: http://localhost:5173") + print('='*60) + return job_id + + +if __name__ == "__main__": + try: + run_chain() + except Exception as e: + print(f"\n❌ 链路中断: {e}") + sys.exit(1) diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..aab9e1c --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Project development utilities.""" diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..fbe1bb3 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,292 @@ +"""Start and inspect the local state-center integration stack. + +Usage: + python scripts/dev.py start + python scripts/dev.py status + python scripts/dev.py stop +""" +from __future__ import annotations + +import argparse +import json +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME_DIR = ROOT / ".runtime" +PID_FILE = RUNTIME_DIR / "dev-pids.json" +MYSQL_CONTAINER = "rpa-center-mysql" +MYSQL_VOLUME = "rpa-center-mysql-data" +MYSQL_IMAGE = "mysql:8" + + +def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]: + """Return backend environment with safe local defaults.""" + env = dict(os.environ if source is None else source) + env.setdefault("DEV_MODE", "true") + env.setdefault( + "MYSQL_DSN", + "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4", + ) + env.setdefault("AGENT_TOKEN", "dev-agent-token") + return env + + +def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ROOT, + check=check, + text=True, + capture_output=capture, + ) + + +def _command_exists(command: str) -> bool: + return subprocess.run( + [command, "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode == 0 + + +def _backend_python() -> str: + configured = os.getenv("STATE_CENTER_PYTHON") + if configured: + return configured + local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if local_python.exists(): + return str(local_python) + return sys.executable + + +def _ensure_backend_runtime() -> str: + python = _backend_python() + probe = subprocess.run( + [python, "-c", "import fastapi, pymysql, uvicorn, cryptography"], + cwd=ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if probe.returncode != 0: + raise RuntimeError( + f"Backend dependencies are missing for {python}. " + f"Install them with: {python} -m pip install -r state_center/requirements.txt" + ) + return python + + +def _docker_container_state() -> str | None: + if not _command_exists("docker"): + return None + result = _run( + ["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER], + check=False, + capture=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def ensure_mysql() -> None: + if not _command_exists("docker"): + raise RuntimeError("Docker is required to start the local MySQL container.") + + state = _docker_container_state() + if state == "running": + return + if state: + _run(["docker", "start", MYSQL_CONTAINER]) + return + + _run( + [ + "docker", + "run", + "--detach", + "--name", + MYSQL_CONTAINER, + "--env", + "MYSQL_ROOT_PASSWORD=root", + "--env", + "MYSQL_DATABASE=rpa_center", + "--publish", + "3306:3306", + "--volume", + f"{MYSQL_VOLUME}:/var/lib/mysql", + "--volume", + f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro", + MYSQL_IMAGE, + ] + ) + + +def _port_open(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.5) + return sock.connect_ex((host, port)) == 0 + + +def wait_for_port(host: str, port: int, timeout: float = 30) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _port_open(host, port): + return + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for {host}:{port}.") + + +def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int: + RUNTIME_DIR.mkdir(exist_ok=True) + log_path = RUNTIME_DIR / f"{name}.log" + log_file = log_path.open("a", encoding="utf-8") + kwargs: dict[str, object] = { + "cwd": cwd, + "env": env, + "stdout": log_file, + "stderr": subprocess.STDOUT, + "stdin": subprocess.DEVNULL, + } + if os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + process = subprocess.Popen(command, **kwargs) + log_file.close() + return process.pid + + +def _read_pids() -> dict[str, int]: + if not PID_FILE.exists(): + return {} + try: + return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()} + except (OSError, ValueError, TypeError): + return {} + + +def _write_pids(pids: dict[str, int]) -> None: + RUNTIME_DIR.mkdir(exist_ok=True) + PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8") + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _service_alive(pid: int, host: str, port: int) -> bool: + return _pid_alive(pid) or _port_open(host, port) + + +def start() -> None: + ensure_mysql() + wait_for_port("127.0.0.1", 3306) + backend_python = _ensure_backend_runtime() + + pids = _read_pids() + backend_env = build_backend_env() + if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000): + pids["backend"] = _spawn( + "backend", + [ + backend_python, + "-m", + "uvicorn", + "state_center.app.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ], + ROOT, + backend_env, + ) + if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173): + pids["frontend"] = _spawn( + "frontend", + ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"], + ROOT / "state_center_web", + os.environ.copy(), + ) + _write_pids(pids) + wait_for_port("127.0.0.1", 8000) + wait_for_port("127.0.0.1", 5173) + print("MySQL: 127.0.0.1:3306") + print("Backend: http://127.0.0.1:8000") + print("Frontend: http://127.0.0.1:5173") + + +def status() -> int: + db = _port_open("127.0.0.1", 3306) + backend = False + backend_detail = "" + try: + with urlopen("http://127.0.0.1:8000/health", timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + backend = response.status == 200 and payload.get("database") == "ok" + backend_detail = json.dumps(payload, ensure_ascii=False) + except (OSError, URLError, json.JSONDecodeError) as exc: + backend_detail = str(exc) + frontend = _port_open("127.0.0.1", 5173) + print(f"MySQL 3306: {'UP' if db else 'DOWN'}") + print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip()) + print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}") + return 0 if db and backend and frontend else 1 + + +def _stop_pid(pid: int) -> None: + try: + if os.name == "nt": + os.kill(pid, signal.SIGTERM) + else: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except OSError: + return + + +def stop() -> None: + for pid in _read_pids().values(): + _stop_pid(pid) + if _docker_container_state() == "running": + _run(["docker", "stop", MYSQL_CONTAINER], check=False) + if PID_FILE.exists(): + PID_FILE.unlink() + print("Local integration stack stopped.") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("start", "status", "stop")) + args = parser.parse_args() + try: + if args.command == "start": + start() + return 0 + if args.command == "status": + return status() + stop() + return 0 + except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: + print(f"dev launcher error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/state_center/README.md b/state_center/README.md new file mode 100644 index 0000000..dc1efb2 --- /dev/null +++ b/state_center/README.md @@ -0,0 +1,124 @@ +# 状态中心(采购主链RPA一期 · 第1步骨架) + +Python + FastAPI + MySQL。库里只存**流程状态 + 对码累积别名 + 证据**;对码货品主数据在 **Oracle**,直连查(不复制)。 + +## 目录 + +``` +state_center/ +├── sql/schema.sql 建库脚本(job 那套 + mapping_config + 证据) +├── app/ +│ ├── config.py 配置(MySQL/Oracle 连接、表名列名,从环境变量读) +│ ├── db.py MySQL 访问(pymysql) +│ ├── match_engine.py 对码引擎(两级命中 + 规格标准化 + Oracle 取数) +│ └── main.py FastAPI:建单 + 回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 +├── tests/test_match_engine.py 对码单测(不依赖 DB/Oracle) +└── requirements.txt +``` + +## 起步 + +以下命令在 macOS/Linux 的终端执行;Windows PowerShell 只需要把激活命令换成下一段对应写法,接口和浏览器端完全相同。 + +```bash +python -m pip install -r state_center/requirements.txt + +# 1) 建库(先起个 MySQL 8) +mysql -uroot -p < state_center/sql/schema.sql + +# 2) 配连接(默认 root/root@127.0.0.1) +export MYSQL_DSN="mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" + +# 3) 起服务 +uvicorn state_center.app.main:app --reload --port 8000 +# 打开 http://127.0.0.1:8000/docs 有全部接口可点 +``` + +Windows PowerShell: + +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r state_center\requirements.txt +$env:DEV_MODE = "true" +$env:MYSQL_DSN = "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" +python -m uvicorn state_center.app.main:app --reload --port 8000 +``` + +浏览器端另开一个终端: + +```bash +cd state_center_web +npm install +npm run dev +``` + +然后打开 `http://localhost:5173/`。Windows 和 macOS 均使用标准浏览器访问,不需要 Electron 或系统专用脚本。`DEV_MODE=true` 只用于本地样例货品和节点回调模拟,生产环境不要开启。 + +本地样例联调顺序:建单 → 写入订单明细(`POST /api/jobs/{job_id}/items`) → 开发接口装载样例货品(`POST /api/dev/match/load-fixture-goods`) → 运行对码。真实环境把样例接口替换为 Oracle 货品装载即可。 + +## 验证第1步(不接真实系统,假回调走全链) + +```bash +# 建单 +curl -X POST http://127.0.0.1:8000/api/jobs -H 'Content-Type: application/json' \ + -d '{"source_type":"YYC","order_date":"2026-08-04","supplier_name":"重庆亿昊药业"}' +# 拿到 job_id 后,模拟艺赛旗回调(带 token): +curl -X POST http://127.0.0.1:8000/api/jobs//nodes/ORDER_FETCH/start \ + -H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' -d '{"attemptNo":1}' +curl -X POST http://127.0.0.1:8000/api/jobs//nodes/ORDER_FETCH/complete \ + -H 'X-Agent-Token: dev-agent-token' -H 'Content-Type: application/json' \ + -d '{"attemptNo":1,"summary":{"orderCount":12}}' +# 反复 start/complete 走完各节点,最后 GET 看整单轨迹: +curl http://127.0.0.1:8000/api/jobs/ +``` + +## 节点编排(13 = 11 主 + 2 条件) + +主链 11 步(每单都走): +`ORDER_FETCH → PRODUCT_MATCH → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB` + +2 个条件节点(不满足条件自动跳过,不算漏节点): + +| 条件节点 | 何时执行 | 何时跳过 | +| --- | --- | --- | +| `OCR_EXTRACT` | 线下手工单(source_type=OFFLINE) | 线上单(医药城/小药药) | +| `SOURCE_ADD` | 对码判定缺货源(needs_source_add=1) | 货源齐全 | + +转移不是"数组下一个",是 `main.py` 里的显式转移表 `TRANSITIONS` + 条件跳过。 +每个节点标了执行方 `NODE_EXECUTOR`:STATE_CENTER(对码,内部算)/ RPA(艺赛旗点页面)/ HUMAN(一期人工)。 +所以文档里"11 个节点"和代码里"13 个"是同一套:**11 主 + 2 条件 = 13**。 + +## 关键设计点(会审确认) + +- **回调幂等**:同节点同 attempt 重复 complete 不会二次推进(靠 job_node 状态 + `callback_idempotency_key`)。 +- **乱序拒绝**:回调节点必须是 job 当前节点,否则 409(避免网络重发把状态推乱)。 +- **合同审批等待**(CONTRACT_APPROVE_WAIT)**不连续轮询**:进 `WAITING_EXTERNAL` 态,每 + `CONTRACT_WAIT_INTERVAL_MIN`(默认 30 分)由调度器扫 `GET /api/scheduler/due-contract-checks` + 触发 RPA 查一次,结果回 `POST .../CONTRACT_APPROVE_WAIT/check-result`;查满 + `CONTRACT_WAIT_MAX_CHECKS`(默认 8 次)还没过就升级 OPS。 +- **对码状态只有 4 种**:EXACT / ALIAS / MULTI / NONE(无 FUZZY;ALIAS=累积别名命中,非当次人工)。 +- **PRODUCT_MATCH 是状态中心自己算的节点**(executor=STATE_CENTER),不走 RPA 三回调,靠 + `POST /api/jobs/{id}/match/run` 推进:跑 `order_item` → 落 `product_match` → MULTI/NONE 建 + 待办转 WAITING_USER,全命中自动往下走;`POST /api/match/{id}/confirm` 人工确认后沉淀 + `mapping_config` 别名并推进。**前置**:`order_item` 已落库 + `POST /api/match/reload-goods` + 从 Oracle 装载过货品主数据(这两样是 Trae 任务2/3 的接真数据部分)。 +- **SOURCE_ADD 触发源明确**:对码拿到 `goods_id` 后,状态中心按历史 `supplier_name + goods_id` + 判断是否已有货源;没历史则写 `product_match.source_add_needed=1`、同步 `job.needs_source_add=1` + 并创建一条聚合的 `SOURCE_ADD` 待办。 +- **对码重跑幂等**:`product_match` 以 `(job_id, order_item_id)` 为当前态唯一键, + `match/run` 重跑时 upsert 结果并重建 OPEN 待办,不重复造结果/待办。 +- **alias 当前态唯一**:`match/confirm` 会先失效同一 `config_key` 的旧 active 记录,再插入新 active; + 装载 alias 时按 `updated_at/id` 固定顺序读取,历史脏数据也有确定结果。 + +## 对码接 Oracle(拿到连接串后) + +1. `pip install oracledb` +2. 在 `config.py` / 环境变量填 `ORACLE_DSN/USER/PASSWORD`,把 `ORACLE_GOODS_TABLE` 和各列名改成真实的。 +3. 开工时 `load_goods_from_oracle(settings)` 拉全量 → `MatchEngine.load_goods(...)`,之后内存匹配。 + +## 对码单测 + +```bash +python3 state_center/tests/test_match_engine.py # 6/6 passed +``` diff --git a/state_center/app/__init__.py b/state_center/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/state_center/app/config.py b/state_center/app/config.py new file mode 100644 index 0000000..8691c14 --- /dev/null +++ b/state_center/app/config.py @@ -0,0 +1,43 @@ +"""配置:从环境变量读,别把密码写死在代码里。""" +import os + + +class Settings: + # 本地联调功能开关。生产环境保持关闭。 + DEV_MODE = os.getenv("DEV_MODE", "false").lower() == "true" + + # MySQL(状态中心自己的库) + MYSQL_DSN = os.getenv( + "MYSQL_DSN", + "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4", + ) + + # Oracle(他们的货品主数据,只读)。拿到真实连接串后填这里。 + # 形如: username/password@host:1521/service_name + ORACLE_DSN = os.getenv("ORACLE_DSN", "") + ORACLE_USER = os.getenv("ORACLE_USER", "") + ORACLE_PASSWORD = os.getenv("ORACLE_PASSWORD", "") + + # Oracle 货品主数据表(真实表名/列名拿到后改这里,先按设计文档 A~G 列占位) + ORACLE_GOODS_TABLE = os.getenv("ORACLE_GOODS_TABLE", "GOODS_MASTER") + ORACLE_COL_COMMON_NAME = os.getenv("ORACLE_COL_COMMON_NAME", "COMMON_NAME") # 通用名 + ORACLE_COL_APPROVAL_NO = os.getenv("ORACLE_COL_APPROVAL_NO", "APPROVAL_NO") # 批准文号 + ORACLE_COL_SPEC = os.getenv("ORACLE_COL_SPEC", "SPEC") # 规格 + ORACLE_COL_UNIT = os.getenv("ORACLE_COL_UNIT", "BASE_UNIT") # 基本单位 + ORACLE_COL_MANUFACTURER = os.getenv("ORACLE_COL_MANUFACTURER", "MANUFACTURER")# 生产厂家 + ORACLE_COL_GOODS_ID = os.getenv("ORACLE_COL_GOODS_ID", "GOODS_ID") # 货品ID + ORACLE_COL_GOODS_CODE = os.getenv("ORACLE_COL_GOODS_CODE", "GOODS_CODE") # 货品编码 + + # 对码货品主数据是否开工时全量 load 进内存(几万行推荐 True,快) + MATCH_LOAD_INTO_MEMORY = os.getenv("MATCH_LOAD_INTO_MEMORY", "true").lower() == "true" + + # RPA 回调鉴权 token + AGENT_TOKEN = os.getenv("AGENT_TOKEN", "dev-agent-token") + + # 合同审批等待(CONTRACT_APPROVE_WAIT):按业务方案定时查,不连续轮询。 + # 到点由调度器触发 RPA 去查一次审批状态,没过就再排下一次。 + CONTRACT_WAIT_INTERVAL_MIN = int(os.getenv("CONTRACT_WAIT_INTERVAL_MIN", "30")) # 每次间隔(分钟),业务方案≈半小时 + CONTRACT_WAIT_MAX_CHECKS = int(os.getenv("CONTRACT_WAIT_MAX_CHECKS", "8")) # 最多查几次,超了升级OPS(8次≈4小时) + + +settings = Settings() diff --git a/state_center/app/db.py b/state_center/app/db.py new file mode 100644 index 0000000..df5d2cf --- /dev/null +++ b/state_center/app/db.py @@ -0,0 +1,21 @@ +"""极简 MySQL 访问层(pymysql)。够第1步用,后面要换 SQLAlchemy 也行。""" +import pymysql +from pymysql.cursors import DictCursor + +from .config import settings + + +def _parse_dsn(dsn: str) -> dict: + # mysql+pymysql://user:pass@host:port/db?charset=utf8mb4 + body = dsn.split("://", 1)[1] + creds, rest = body.split("@", 1) + user, password = creds.split(":", 1) + hostport, dbq = rest.split("/", 1) + host, port = (hostport.split(":", 1) + ["3306"])[:2] + db = dbq.split("?", 1)[0] + return dict(host=host, port=int(port), user=user, password=password, database=db) + + +def get_conn(): + cfg = _parse_dsn(settings.MYSQL_DSN) + return pymysql.connect(charset="utf8mb4", cursorclass=DictCursor, autocommit=True, **cfg) diff --git a/state_center/app/main.py b/state_center/app/main.py new file mode 100644 index 0000000..7f880ab --- /dev/null +++ b/state_center/app/main.py @@ -0,0 +1,1342 @@ +""" +状态中心 · FastAPI 入口(第1步骨架)。 + +提供: + - 建单 POST /api/jobs + - 节点开始 POST /api/jobs/{job_id}/nodes/{node_code}/start + - 节点成功 POST /api/jobs/{job_id}/nodes/{node_code}/complete + - 节点失败 POST /api/jobs/{job_id}/nodes/{node_code}/fail + - 合同审批查一次 POST /api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result + - 到点待查列表(调度器扫) GET /api/scheduler/due-contract-checks + - 对码运行(内部算) POST /api/jobs/{job_id}/match/run + - 对码人工确认 POST /api/match/{match_id}/confirm + - 装载货品主数据 POST /api/match/reload-goods + - 整单查询(画树状图用) GET /api/jobs/{job_id} + +鉴权:RPA 回调带 header X-Agent-Token。 + +节点编排(13 个 = 11 主节点 + 2 条件节点): + - 主链 11 步:ORDER_FETCH → PRODUCT_MATCH → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT + → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER + → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB + - 2 条件节点:OCR_EXTRACT(仅线下单)、SOURCE_ADD(仅对码判定缺货源)。 + 条件不满足则自动跳过,不占流程。 + +转移不是"数组下一个",是显式转移表 + 条件节点判定(见 _next_node)。 +每个节点标了执行方(STATE_CENTER 编排 / RPA 执行 / HUMAN 人工挂起),见 NODE_EXECUTOR。 +回调做了幂等(重复 complete 不二次推进)和乱序拒绝(非当前节点回调返回 409)。 +""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import FastAPI, Header, HTTPException +from pydantic import BaseModel + +from .config import settings +from .db import get_conn +from .match_engine import GoodsRecord, MatchEngine, build_match_key, load_goods_from_oracle +from .web_state_machine import build_state_machine_meta, build_timeline + +app = FastAPI(title="采购主链RPA状态中心", version="0.1") + +# 全部 13 个节点(11 主 + 2 条件),仅用于展示口径 / 前端画图 / 校验节点名合法。 +# 顺序不代表实际转移,实际转移看 TRANSITIONS + _next_node。 +NODE_ORDER = [ + "ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD", + "CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER", + "REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER", + "M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB", +] + +# 显式转移表:node -> 下一个"直连"节点(不含条件跳过逻辑,跳过在 _should_skip 处理)。 +# None = 整单终点。条件节点(OCR_EXTRACT/SOURCE_ADD)也在链里,但可能被跳过。 +TRANSITIONS = { + "ORDER_FETCH": "OCR_EXTRACT", + "OCR_EXTRACT": "PRODUCT_MATCH", + "PRODUCT_MATCH": "SOURCE_ADD", + "SOURCE_ADD": "CONTRACT_CREATE", + "CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT", + "CONTRACT_APPROVE_WAIT": "REBATE_HEADER", + "REBATE_HEADER": "REBATE_IMPORT", + "REBATE_IMPORT": "REBATE_SUBMIT", + "REBATE_SUBMIT": "M5_CREATE_ORDER", + "M5_CREATE_ORDER": "M5_MANUAL_CONFIRM", + "M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE", + "M5_RECEIPT_NOTICE": "ARCHIVE_JOB", + "ARCHIVE_JOB": None, +} + +# 执行方标注:谁真正干这个节点。 +# STATE_CENTER = 状态中心内部算(对码),不派 RPA、不挂人工。 +# RPA = 艺赛旗点页面。 +# HUMAN = 一期人工做(状态中心只挂待办等回填)。 +NODE_EXECUTOR = { + "ORDER_FETCH": "RPA", + "OCR_EXTRACT": "RPA", # 线下单触发 OCR;线上单跳过 + "PRODUCT_MATCH": "STATE_CENTER", # 对码是状态中心内部算 + "SOURCE_ADD": "HUMAN", # 缺货源时人工在 M5 新增货源 + "CONTRACT_CREATE": "RPA", + "CONTRACT_APPROVE_WAIT": "RPA", # 到点由调度器触发 RPA 查一次审批 + "REBATE_HEADER": "RPA", + "REBATE_IMPORT": "RPA", + "REBATE_SUBMIT": "RPA", + "M5_CREATE_ORDER": "RPA", + "M5_MANUAL_CONFIRM": "HUMAN", # 一期人工检查/保存/签订 + "M5_RECEIPT_NOTICE": "HUMAN", # 一期人工生成收货通知单 + "ARCHIVE_JOB": "STATE_CENTER", +} + +HUMAN_NODE_ROLES = { + "SOURCE_ADD": "PURCHASER", + "M5_MANUAL_CONFIRM": "PURCHASER", + "M5_RECEIPT_NOTICE": "PURCHASER", +} + +HUMAN_TASK_TITLES = { + "M5_MANUAL_CONFIRM": "M5人工确认", + "M5_RECEIPT_NOTICE": "生成收货通知单", +} + +HUMAN_TASK_REASONS = { + "M5_MANUAL_CONFIRM": "M5采购单已录入待保存,需要人工核对、保存并签订。", + "M5_RECEIPT_NOTICE": "M5采购单已签订,需要生成收货通知单并回填单号。", +} + +# 条件节点:满足条件才执行,否则自动跳过下一节点。 +CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"} + +# 业务编号回写:哪个节点产出哪个 job 字段 +BUSINESS_REF_FIELD = { + "contractSystemNo": "contract_system_no", + "rebateOrderNo": "rebate_order_no", + "m5OrderNo": "m5_order_no", + "receiptNoticeNo": "receipt_notice_no", +} + + +def _now(): + return datetime.now() + + +def _check_agent(token: Optional[str]): + if token != settings.AGENT_TOKEN: + raise HTTPException(status_code=401, detail="bad agent token") + + +def _should_skip(node_code: str, job: dict) -> bool: + """条件节点是否跳过。 + - OCR_EXTRACT:只有线下单(OFFLINE)才做 OCR,线上单跳过。 + - SOURCE_ADD:只有对码判定缺货源(needs_source_add=1)才做,否则跳过。 + """ + if node_code == "OCR_EXTRACT": + return job.get("source_type") != "OFFLINE" + if node_code == "SOURCE_ADD": + return not job.get("needs_source_add") + return False + + +def _next_node(node_code: str, job: dict) -> Optional[str]: + """按显式转移表找下一节点;遇到不满足条件的条件节点自动继续往后跳。""" + nxt = TRANSITIONS.get(node_code) + # 一路跳过不满足条件的条件节点 + while nxt is not None and nxt in CONDITIONAL_NODES and _should_skip(nxt, job): + nxt = TRANSITIONS.get(nxt) + return nxt + + +def _advance(cur, job: dict, node_code: str) -> Optional[str]: + """把 job 从 node_code 推进到下一节点,处理终点/合同审批等待/普通三种落法。 + 返回 next_node(None=整单结束)。调用方需保证 node_code 已记 SUCCESS。""" + job_id = job["job_id"] + nxt = _next_node(node_code, job) + if nxt is None: + cur.execute("UPDATE job SET status='SUCCESS',finished_at=%s,updated_at=%s WHERE job_id=%s", + (_now(), _now(), job_id)) + _log_event(cur, job_id, "JOB_FINISHED", None, "SYSTEM", None) + elif nxt == "CONTRACT_APPROVE_WAIT": + # 进等待外部态:不立刻派 RPA,挂 WAITING_EXTERNAL,到点由调度器触发查一次。 + next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN) + cur.execute( + "UPDATE job SET current_node_code=%s,status='WAITING_EXTERNAL'," + "next_check_at=%s,wait_check_count=0,updated_at=%s WHERE job_id=%s", + (nxt, next_check, _now(), job_id), + ) + _log_event(cur, job_id, "NODE_STARTED", nxt, "SYSTEM", None, + {"wait": "CONTRACT_APPROVE", "next_check_at": next_check.isoformat()}) + elif nxt in HUMAN_NODE_ROLES: + role_code = HUMAN_NODE_ROLES[nxt] + _mark_node_waiting_user(cur, job_id, nxt) + cur.execute( + "UPDATE job SET current_node_code=%s,status='WAITING_USER'," + "current_role=%s,updated_at=%s WHERE job_id=%s", + (nxt, role_code, _now(), job_id), + ) + if nxt != "SOURCE_ADD": + _create_human_task(cur, job, nxt, role_code) + _log_event(cur, job_id, "MANUAL_HANDOFF", nxt, "SYSTEM", None, + {"role_code": role_code}) + else: + cur.execute("UPDATE job SET current_node_code=%s,status='PENDING',updated_at=%s WHERE job_id=%s", + (nxt, _now(), job_id)) + return nxt + + +def _log_event(cur, job_id, event_type, node_code, operator_type, operator, detail=None): + cur.execute( + "INSERT INTO job_event(job_id,event_type,node_code,operator_type,operator,detail_json,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s)", + (job_id, event_type, node_code, operator_type, operator, + json.dumps(detail, ensure_ascii=False) if detail else None, _now()), + ) + + +def _serialize_visible_roles(visible_roles: Optional[list[str]]) -> Optional[str]: + if visible_roles is None: + return None + return json.dumps(visible_roles, ensure_ascii=False) + + +def _parse_visible_roles(value): + if value in (None, ""): + return [] + if isinstance(value, list): + return [str(item) for item in value] + try: + parsed = json.loads(value) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, list): + return [str(item) for item in parsed] + return [part.strip() for part in str(value).split(",") if part.strip()] + + +def _record_job_artifacts(cur, job_id: str, node_code: str, attempt_no: int, + artifacts: Optional[list["NodeArtifactReq"]]): + if not artifacts: + return + for artifact in artifacts: + cur.execute( + "INSERT INTO job_artifact(job_id,node_code,attempt_no,artifact_type,storage_path," + "file_hash,visible_roles,is_masked,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + job_id, + node_code, + attempt_no, + artifact.artifactType, + artifact.storagePath, + artifact.fileHash, + _serialize_visible_roles(artifact.visibleRoles), + 1 if artifact.isMasked else 0, + _now(), + ), + ) + + +def _fetch_job_artifacts(cur, job_id: str) -> list[dict]: + cur.execute( + "SELECT node_code,attempt_no,artifact_type,storage_path,file_hash,visible_roles," + "is_masked,created_at FROM job_artifact WHERE job_id=%s ORDER BY id", + (job_id,), + ) + rows = cur.fetchall() + out = [] + for row in rows: + out.append({ + "node_code": row.get("node_code"), + "attempt_no": row.get("attempt_no"), + "artifact_type": row.get("artifact_type"), + "storage_path": row.get("storage_path"), + "file_hash": row.get("file_hash"), + "visible_roles": _parse_visible_roles(row.get("visible_roles")), + "is_masked": bool(row.get("is_masked")), + "created_at": row.get("created_at"), + }) + return out + + +def _mark_node_waiting_user(cur, job_id: str, node_code: str): + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s)" + " ON DUPLICATE KEY UPDATE status=VALUES(status),started_at=COALESCE(started_at,VALUES(started_at))", + (job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), 1, "WAITING_USER", _now(), _now()), + ) + + +def _mark_node_success(cur, job_id: str, node_code: str): + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)" + " VALUES(%s,%s,%s,%s,'SUCCESS',%s,%s,%s)" + " ON DUPLICATE KEY UPDATE status='SUCCESS',ended_at=VALUES(ended_at)", + (job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), 1, _now(), _now(), _now()), + ) + + +def _create_human_task(cur, job: dict, node_code: str, role_code: str): + job_id = job["job_id"] + cur.execute( + "DELETE FROM user_task WHERE job_id=%s AND node_code=%s AND status='OPEN'", + (job_id, node_code), + ) + title_prefix = HUMAN_TASK_TITLES.get(node_code, node_code) + cur.execute( + "INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type," + "reason_user_friendly,why_me_text,status,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,'OPEN',%s)", + ( + uuid.uuid4().hex, + job_id, + node_code, + role_code, + f"{title_prefix}:{job['supplier_name']}", + node_code, + HUMAN_TASK_REASONS.get(node_code), + "该节点一期由人工处理,确认完成后流程继续。", + _now(), + ), + ) + + +def _supplier_has_source(cur, supplier_name: str, goods_id: str, current_job_id: str) -> bool: + """用状态中心历史结果判断该供应商是否已有该货品货源。 + + 一期没有 M5 货源 API,所以用历史已确认/已命中的 supplier+goods_id 作为明确触发源。 + """ + cur.execute( + "SELECT 1 FROM job j JOIN product_match pm ON pm.job_id=j.job_id" + " WHERE j.supplier_name=%s AND pm.goods_id=%s AND j.job_id<>%s" + " AND pm.match_status IN ('EXACT','ALIAS') LIMIT 1", + (supplier_name, goods_id, current_job_id), + ) + return cur.fetchone() is not None + + +def _source_add_needed(cur, job: dict, goods_id: Optional[str]) -> int: + if not goods_id: + return 0 + return 0 if _supplier_has_source(cur, job["supplier_name"], goods_id, job["job_id"]) else 1 + + +def _build_source_add_candidates(cur, job_id: str) -> list[dict]: + """从 product_match 当前态生成 SOURCE_ADD 待办明细,按 goods_id 去重。""" + cur.execute( + "SELECT pm.id AS product_match_id,pm.order_item_id,pm.goods_id,pm.goods_code," + "pm.base_unit,pm.match_key,oi.raw_product_name,oi.spec,oi.manufacturer" + " FROM product_match pm JOIN order_item oi ON oi.id=pm.order_item_id" + " WHERE pm.job_id=%s AND pm.source_add_needed=1 AND pm.goods_id IS NOT NULL" + " ORDER BY pm.id", + (job_id,), + ) + out = [] + seen_goods = set() + for row in cur.fetchall(): + goods_id = row["goods_id"] + if goods_id in seen_goods: + continue + seen_goods.add(goods_id) + out.append({ + "goods_id": goods_id, + "goods_code": row["goods_code"], + "base_unit": row["base_unit"], + "raw_product_name": row["raw_product_name"], + "spec": row["spec"], + "manufacturer": row["manufacturer"], + "order_item_id": row["order_item_id"], + "product_match_id": row["product_match_id"], + "match_key": row["match_key"], + }) + return out + + +def _sync_source_add_task(cur, job: dict) -> list[dict]: + """把 product_match.source_add_needed 同步到 job.needs_source_add 和一条聚合待办。""" + job_id = job["job_id"] + candidates = _build_source_add_candidates(cur, job_id) + cur.execute( + "DELETE FROM user_task WHERE job_id=%s AND node_code='SOURCE_ADD' AND status='OPEN'", + (job_id,), + ) + cur.execute( + "UPDATE job SET needs_source_add=%s,updated_at=%s WHERE job_id=%s", + (1 if candidates else 0, _now(), job_id), + ) + if not candidates: + return [] + + detail = { + "supplier_name": job["supplier_name"], + "items": candidates, + } + cur.execute( + "INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type," + "reason_user_friendly,why_me_text,comparison_snapshot_json,candidates_json,status,created_at)" + " VALUES(%s,%s,'SOURCE_ADD','PURCHASER',%s,'SOURCE_ADD',%s,%s,%s,%s,'OPEN',%s)", + ( + uuid.uuid4().hex, + job_id, + f"新增货源:{job['supplier_name']}", + "该供应商未维护这些品种的货源,需要在 M5 新增后继续。", + "供应商货源由采购/业务在 M5 维护。", + json.dumps(detail, ensure_ascii=False), + json.dumps(candidates, ensure_ascii=False), + _now(), + ), + ) + _log_event(cur, job_id, "TASK_CREATED", "SOURCE_ADD", "SYSTEM", None, detail) + return candidates + + +# ---------------- 建单 ---------------- + +class CreateJobReq(BaseModel): + source_type: str # YYC/XYY/OFFLINE + order_date: str # YYYY-MM-DD + supplier_name: str + created_by: str = "system" + + +class OrderItemRowReq(BaseModel): + orderNo: str + rawProductName: str + platformProductCode: Optional[str] = None + spec: Optional[str] = None + manufacturer: Optional[str] = None + approvalNo: Optional[str] = None + qty: float + unitPrice: Optional[float] = None + sourceRowNo: Optional[int] = None + + +class OrderItemsIngestReq(BaseModel): + replace: bool = True + rows: list[OrderItemRowReq] + + +@app.post("/api/jobs") +def create_job(req: CreateJobReq): + job_id = f"{req.source_type}-{req.supplier_name}-{req.order_date.replace('-', '')}-{uuid.uuid4().hex[:6]}" + business_key = f"{req.source_type}|{req.supplier_name}|{req.order_date}" + need_pdf = 1 if req.source_type == "YYC" else 0 + first_node = NODE_ORDER[0] + + conn = get_conn() + with conn.cursor() as cur: + # 幂等:同 business_key 已有未结束 job 就不重复建 + cur.execute( + "SELECT job_id FROM job WHERE business_key=%s AND status NOT IN" + " ('SUCCESS','CANCELLED','FAILED_MANUAL') LIMIT 1", + (business_key,), + ) + exist = cur.fetchone() + if exist: + return {"job_id": exist["job_id"], "reused": True} + + cur.execute( + "INSERT INTO job(job_id,business_key,source_type,order_date,supplier_name," + "need_pdf_upload,current_node_code,status,auto_save_enabled,created_by,created_at,updated_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s,'PENDING',0,%s,%s,%s)", + (job_id, business_key, req.source_type, req.order_date, req.supplier_name, + need_pdf, first_node, req.created_by, _now(), _now()), + ) + _log_event(cur, job_id, "JOB_CREATED", None, "SYSTEM", req.created_by, + {"business_key": business_key}) + return {"job_id": job_id, "first_node": first_node, "reused": False} + + +@app.post("/api/jobs/{job_id}/items") +def ingest_order_items(job_id: str, req: OrderItemsIngestReq, + x_agent_token: Optional[str] = Header(default=None)): + """开发/接入侧写入订单明细。已有对码结果后禁止改写,避免破坏下游状态。""" + _check_agent(x_agent_token) + conn = get_conn() + with conn.cursor() as cur: + _load_job(cur, job_id) + cur.execute("SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s", (job_id,)) + if cur.fetchone()["c"]: + raise HTTPException(status_code=409, detail="已有对码结果,不能覆盖订单明细") + + if req.replace: + cur.execute("DELETE FROM order_item WHERE job_id=%s", (job_id,)) + + order_nos = [] + for row in req.rows: + if row.orderNo not in order_nos: + order_nos.append(row.orderNo) + cur.execute( + "INSERT INTO order_item(job_id,order_no,raw_product_name,platform_product_code," + "spec,manufacturer,approval_no,qty,unit_price,source_row_no,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + job_id, + row.orderNo, + row.rawProductName, + row.platformProductCode, + row.spec, + row.manufacturer, + row.approvalNo, + row.qty, + row.unitPrice, + row.sourceRowNo, + _now(), + ), + ) + + cur.execute( + "UPDATE job SET order_no_list_json=%s,updated_at=%s WHERE job_id=%s", + (json.dumps(order_nos, ensure_ascii=False), _now(), job_id), + ) + _log_event(cur, job_id, "ORDER_ITEMS_INGESTED", "ORDER_FETCH", "ROBOT", None, + {"replace": req.replace, "inserted": len(req.rows), "order_nos": order_nos}) + return {"ok": True, "inserted": len(req.rows), "order_nos": order_nos} + + +@app.get("/api/jobs") +def list_jobs(status: Optional[str] = None, source_type: Optional[str] = None, + supplier_name: Optional[str] = None, date_from: Optional[str] = None, + date_to: Optional[str] = None): + conn = get_conn() + sql = ( + "SELECT job_id,supplier_name,order_date,source_type,status,current_node_code," + "current_role,created_at,updated_at,finished_at FROM job WHERE 1=1" + ) + params = [] + if status: + sql += " AND status=%s" + params.append(status) + if source_type: + sql += " AND source_type=%s" + params.append(source_type) + if supplier_name: + sql += " AND supplier_name LIKE %s" + params.append(f"%{supplier_name}%") + if date_from: + sql += " AND order_date>=%s" + params.append(date_from) + if date_to: + sql += " AND order_date<=%s" + params.append(date_to) + sql += " ORDER BY created_at DESC" + with conn.cursor() as cur: + cur.execute(sql, tuple(params)) + rows = cur.fetchall() + return {"items": rows} + + +@app.get("/api/tasks/my") +def list_my_tasks(roleCode: Optional[str] = None, status: Optional[str] = None): + conn = get_conn() + sql = ( + "SELECT task_id,job_id,node_code,order_item_id,product_match_id,role_code,status,title," + "reason_type,reason_user_friendly,why_me_text,candidates_json,submitted_action," + "submitted_at,created_at FROM user_task WHERE 1=1" + ) + params = [] + if roleCode: + sql += " AND role_code=%s" + params.append(roleCode) + if status: + sql += " AND status=%s" + params.append(status.upper()) + sql += " ORDER BY created_at DESC" + with conn.cursor() as cur: + cur.execute(sql, tuple(params)) + rows = cur.fetchall() + return {"items": rows} + + +# ---------------- 节点开始 ---------------- + +class NodeStartReq(BaseModel): + attemptNo: int = 1 + executorPlatform: Optional[str] = None + operator: Optional[str] = "agent" + + +def _load_job(cur, job_id: str) -> dict: + cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) + job = cur.fetchone() + if not job: + raise HTTPException(status_code=404, detail="job not found") + return job + + +def _guard_current_node(job: dict, node_code: str): + """乱序拒绝:回调的节点必须是该 job 当前节点,否则 409。 + 避免 RPA 端网络重发/顺序错乱把状态推乱。""" + if node_code not in NODE_ORDER: + raise HTTPException(status_code=400, detail=f"unknown node {node_code}") + if job.get("current_node_code") != node_code: + raise HTTPException( + status_code=409, + detail=f"out-of-order: job 当前在 {job.get('current_node_code')},拒绝 {node_code} 回调", + ) + + +@app.post("/api/jobs/{job_id}/nodes/{node_code}/start") +def node_start(job_id: str, node_code: str, req: NodeStartReq, + x_agent_token: Optional[str] = Header(default=None)): + _check_agent(x_agent_token) + conn = get_conn() + with conn.cursor() as cur: + job = _load_job(cur, job_id) + _guard_current_node(job, node_code) + + # 幂等:该节点该次尝试已 RUNNING/SUCCESS,直接回 ok,不重复起点 + cur.execute( + "SELECT status FROM job_node WHERE job_id=%s AND node_code=%s AND attempt_no=%s", + (job_id, node_code, req.attemptNo), + ) + row = cur.fetchone() + if row and row["status"] in ("RUNNING", "SUCCESS"): + return {"ok": True, "idempotent": True} + + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,executor_platform," + "executor_run_id,started_at,created_at) VALUES(%s,%s,%s,%s,'RUNNING',%s,NULL,%s,%s)" + " ON DUPLICATE KEY UPDATE status='RUNNING',started_at=VALUES(started_at)", + (job_id, node_code, NODE_EXECUTOR.get(node_code, "RPA"), req.attemptNo, + req.executorPlatform, _now(), _now()), + ) + cur.execute( + "UPDATE job SET status='RUNNING',updated_at=%s WHERE job_id=%s", + (_now(), job_id), + ) + _log_event(cur, job_id, "NODE_STARTED", node_code, "ROBOT", req.operator) + return {"ok": True} + + +# ---------------- 节点成功 ---------------- + +class NodeArtifactReq(BaseModel): + artifactType: str + storagePath: str + fileHash: Optional[str] = None + visibleRoles: Optional[list[str]] = None + isMasked: bool = False + + +class NodeCompleteReq(BaseModel): + attemptNo: int = 1 + executorRunId: Optional[str] = None + idempotencyKey: Optional[str] = None # RPA 端生成的回调幂等键,重发同键不二次推进 + businessRefs: Optional[dict] = None # {contractSystemNo:..., rebateOrderNo:...} + summary: Optional[dict] = None + artifacts: Optional[list[NodeArtifactReq]] = None + endedAt: Optional[str] = None + + +@app.post("/api/jobs/{job_id}/nodes/{node_code}/complete") +def node_complete(job_id: str, node_code: str, req: NodeCompleteReq, + x_agent_token: Optional[str] = Header(default=None)): + _check_agent(x_agent_token) + + # 约束:要求业务编号却没回传的节点,不许记 SUCCESS + required = {"CONTRACT_CREATE": "contractSystemNo", "REBATE_HEADER": "rebateOrderNo", + "M5_RECEIPT_NOTICE": "receiptNoticeNo"} + if node_code in required: + key = required[node_code] + if not (req.businessRefs or {}).get(key): + raise HTTPException(status_code=400, detail=f"{node_code} 必须回传 {key}") + + conn = get_conn() + with conn.cursor() as cur: + job = _load_job(cur, job_id) + + # 幂等:该节点该次尝试已 SUCCESS,直接回上次算出的 next,不二次推进 + cur.execute( + "SELECT status,callback_idempotency_key FROM job_node" + " WHERE job_id=%s AND node_code=%s AND attempt_no=%s", + (job_id, node_code, req.attemptNo), + ) + prev = cur.fetchone() + if prev and prev["status"] == "SUCCESS": + return {"ok": True, "next_node": _next_node(node_code, job), "idempotent": True} + + # 乱序拒绝:非当前节点的 complete 一律 409(幂等重发已在上面放行) + _guard_current_node(job, node_code) + + cur.execute( + "UPDATE job_node SET status='SUCCESS',executor_run_id=%s,ended_at=%s," + "callback_idempotency_key=%s,output_json=%s" + " WHERE job_id=%s AND node_code=%s AND attempt_no=%s", + (req.executorRunId, _now(), req.idempotencyKey, + json.dumps({"businessRefs": req.businessRefs, "summary": req.summary}, ensure_ascii=False), + job_id, node_code, req.attemptNo), + ) + # 回写业务编号(三把钥匙等) + if req.businessRefs: + for k, v in req.businessRefs.items(): + col = BUSINESS_REF_FIELD.get(k) + if col and v: + cur.execute(f"UPDATE job SET {col}=%s,updated_at=%s WHERE job_id=%s", + (v, _now(), job_id)) + _record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts) + if node_code == "SOURCE_ADD": + cur.execute( + "UPDATE job SET needs_source_add=0,updated_at=%s WHERE job_id=%s", + (_now(), job_id), + ) + cur.execute( + "UPDATE user_task SET status='SUBMITTED',submitted_at=%s" + " WHERE job_id=%s AND node_code='SOURCE_ADD' AND status='OPEN'", + (_now(), job_id), + ) + _log_event(cur, job_id, "NODE_SUCCESS", node_code, "ROBOT", None, req.summary) + + # 决定下一步(显式转移表 + 条件节点跳过) + nxt = _advance(cur, job, node_code) + return {"ok": True, "next_node": nxt} + + +# ---------------- 节点失败 ---------------- + +class NodeFailReq(BaseModel): + attemptNo: int = 1 + errorCode: str + errorSummary: Optional[str] = None + retryable: bool = False + routeRole: Optional[str] = None + artifacts: Optional[list[NodeArtifactReq]] = None + + +@app.post("/api/jobs/{job_id}/nodes/{node_code}/fail") +def node_fail(job_id: str, node_code: str, req: NodeFailReq, + x_agent_token: Optional[str] = Header(default=None)): + _check_agent(x_agent_token) + + # M5 质管缺失:不重试、不等质管,直接转人工手工做 + force_manual = req.errorCode == "M5_QC_MISSING" + + conn = get_conn() + with conn.cursor() as cur: + job = _load_job(cur, job_id) + _guard_current_node(job, node_code) # 只有当前节点能报失败,乱序 409 + + cur.execute( + "UPDATE job_node SET status='FAILED',error_code=%s,error_summary=%s," + "role_at_failure=%s,ended_at=%s WHERE job_id=%s AND node_code=%s AND attempt_no=%s", + (req.errorCode, req.errorSummary, req.routeRole, _now(), + job_id, node_code, req.attemptNo), + ) + _record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts) + if force_manual: + job_status = "FAILED_MANUAL" + elif req.retryable: + job_status = "FAILED_RETRYABLE" + else: + job_status = "WAITING_USER" # 业务类转人工待办 + cur.execute("UPDATE job SET status=%s,current_role=%s,updated_at=%s WHERE job_id=%s", + (job_status, req.routeRole, _now(), job_id)) + _log_event(cur, job_id, "NODE_FAILED", node_code, "ROBOT", None, + {"errorCode": req.errorCode, "retryable": req.retryable}) + return {"ok": True, "job_status": job_status} + + +# ---------------- 合同审批等待(定时查一次,不连续轮询) ---------------- + +@app.get("/api/scheduler/due-contract-checks") +def due_contract_checks(x_agent_token: Optional[str] = Header(default=None)): + """调度器/RPA 定时扫这个:到点该查审批的 job 列表。 + 业务方案:送审后每 CONTRACT_WAIT_INTERVAL_MIN 分钟查一次,不死等。""" + _check_agent(x_agent_token) + conn = get_conn() + with conn.cursor() as cur: + cur.execute( + "SELECT job_id,contract_system_no,wait_check_count,next_check_at FROM job" + " WHERE status='WAITING_EXTERNAL' AND current_node_code='CONTRACT_APPROVE_WAIT'" + " AND next_check_at IS NOT NULL AND next_check_at<=%s ORDER BY next_check_at", + (_now(),), + ) + rows = cur.fetchall() + return {"due": rows, "interval_min": settings.CONTRACT_WAIT_INTERVAL_MIN, + "max_checks": settings.CONTRACT_WAIT_MAX_CHECKS} + + +class ContractCheckReq(BaseModel): + approved: bool # RPA 查完:审批是否已通过 + operator: Optional[str] = "agent" + detail: Optional[str] = None + + +@app.post("/api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result") +def contract_check_result(job_id: str, req: ContractCheckReq, + x_agent_token: Optional[str] = Header(default=None)): + """RPA 到点登录查了一次审批状态,把结果回给状态中心。 + - 通过 → 该节点 SUCCESS,推进到 REBATE_HEADER。 + - 未通过 → 次数+1;没超上限则排下一次查(next_check_at);超了升级 OPS(WAITING_USER)。 + """ + _check_agent(x_agent_token) + conn = get_conn() + with conn.cursor() as cur: + job = _load_job(cur, job_id) + if job.get("current_node_code") != "CONTRACT_APPROVE_WAIT" or job.get("status") != "WAITING_EXTERNAL": + raise HTTPException(status_code=409, + detail=f"job 不在合同审批等待态(当前 {job.get('current_node_code')}/{job.get('status')})") + + # 本次是第几次查(1-based)。每次查都单独落一行 job_node(不覆盖历史), + # attempt_no 递增 → 命中 uniq_job_node_attempt 也不冲突,审计轨迹完整。 + attempt = int(job.get("wait_check_count") or 0) + 1 + + if req.approved: + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status," + "started_at,ended_at,created_at) VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,'SUCCESS',%s,%s,%s)", + (job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt, _now(), _now(), _now()), + ) + nxt = _next_node("CONTRACT_APPROVE_WAIT", job) # REBATE_HEADER + cur.execute( + "UPDATE job SET current_node_code=%s,status='PENDING',wait_check_count=%s," + "next_check_at=NULL,updated_at=%s WHERE job_id=%s", + (nxt, attempt, _now(), job_id), + ) + _log_event(cur, job_id, "NODE_SUCCESS", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator, + {"approved": True, "checks": attempt}) + return {"ok": True, "next_node": nxt} + + # 未通过:本次查也单独落一行(记为一次尝试),再决定重排还是升级 + over_limit = attempt >= settings.CONTRACT_WAIT_MAX_CHECKS + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,error_code," + "error_summary,role_at_failure,started_at,ended_at,created_at)" + " VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,%s,%s,%s,%s,%s,%s,%s)", + (job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt, + "FAILED" if over_limit else "WAITING_EXTERNAL", + "CONTRACT_APPROVE_TIMEOUT" if over_limit else None, + req.detail, "OPS" if over_limit else None, _now(), _now(), _now()), + ) + if over_limit: + # 超上限:升级 OPS 人工介入 + cur.execute( + "UPDATE job SET status='WAITING_USER',current_role='OPS'," + "wait_check_count=%s,next_check_at=NULL,updated_at=%s WHERE job_id=%s", + (attempt, _now(), job_id), + ) + _log_event(cur, job_id, "MANUAL_HANDOFF", "CONTRACT_APPROVE_WAIT", "SYSTEM", None, + {"reason": "approve_timeout", "checks": attempt}) + return {"ok": True, "escalated": True, "job_status": "WAITING_USER"} + + # 没超:排下一次查 + next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN) + cur.execute( + "UPDATE job SET wait_check_count=%s,next_check_at=%s,updated_at=%s WHERE job_id=%s", + (attempt, next_check, _now(), job_id), + ) + _log_event(cur, job_id, "NODE_STARTED", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator, + {"approved": False, "checks": attempt, "next_check_at": next_check.isoformat()}) + return {"ok": True, "approved": False, "checks": attempt, "next_check_at": next_check.isoformat()} + + +# ---------------- 对码(PRODUCT_MATCH,状态中心内部算) ---------------- +# +# 骨架说明:对码是状态中心自己算的节点(executor=STATE_CENTER),不派 RPA。 +# 依赖两样东西,拿到后本节点才真正跑得动: +# 1) order_item 已落库(建单/ORDER_FETCH 后把订单明细写进来)——Trae 任务; +# 2) 货品主数据装载进 MatchEngine(开工时从 Oracle 全量拉)——需 Oracle 真连。 +# 当前给出可推进的骨架:有明细就跑 match_engine 落 product_match、判 needs_source_add、 +# MULTI/NONE 建 user_task 转人工,全命中则推进下一节点;没明细/引擎没装载则明确报错,不静默卡。 + +# 进程内对码引擎(开工 reload 一次,后续内存匹配)。多进程部署时各进程各自 reload。 +_ENGINE = MatchEngine() +_ENGINE_LOADED = False + + +@app.post("/api/match/reload-goods") +def reload_goods(x_agent_token: Optional[str] = Header(default=None)): + """从 Oracle 全量拉货品主数据装载进内存引擎。需先配好 ORACLE_* 且 pip install oracledb。""" + _check_agent(x_agent_token) + if not settings.ORACLE_DSN: + raise HTTPException(status_code=400, detail="未配置 ORACLE_DSN,无法装载货品主数据") + global _ENGINE_LOADED + records = load_goods_from_oracle(settings) + _ENGINE.load_goods(records) + _ENGINE_LOADED = True + return {"ok": True, "loaded": len(records)} + + +@app.post("/api/dev/match/load-fixture-goods") +def load_fixture_goods(x_agent_token: Optional[str] = Header(default=None)): + """仅本地联调用,不依赖 Oracle 装载一组可重复的货品样例。""" + _check_agent(x_agent_token) + if not settings.DEV_MODE: + raise HTTPException(status_code=404, detail="开发接口未启用") + + records = [ + GoodsRecord("阿卡波糖片", "国药准字H00000001", "50mg*30片", "盒", "示例制药", "G-001", "YP-001"), + GoodsRecord("阿莫西林胶囊", "国药准字H00000002", "0.25g*24粒", "盒", "示例医药", "G-002", "YP-002"), + GoodsRecord("氯化钠注射液", "国药准字H00000003", "100ml", "瓶", "示例药业", "G-003", "YP-003"), + ] + global _ENGINE_LOADED + _ENGINE.load_goods(records) + _ENGINE_LOADED = True + return {"ok": True, "loaded": len(records)} + + +def _load_aliases_into_engine(cur) -> int: + """把 mapping_config(PRODUCT,active)里的累积别名装进引擎 alias_map。 + target_value 存的是 {"goods_id":..,"goods_code":..,"base_unit":..} 的 JSON。""" + cur.execute( + "SELECT config_key,target_value FROM mapping_config" + " WHERE config_type='PRODUCT' AND is_active=1" + " ORDER BY config_key,updated_at,id", + ) + alias = {} + for row in cur.fetchall(): + try: + tv = json.loads(row["target_value"]) if row["target_value"] else {} + except (TypeError, ValueError): + continue + gid = tv.get("goods_id") + if gid: + alias[row["config_key"]] = (gid, tv.get("goods_code", ""), tv.get("base_unit", "")) + _ENGINE.load_aliases(alias) + return len(alias) + + +@app.post("/api/jobs/{job_id}/match/run") +def match_run(job_id: str, x_agent_token: Optional[str] = Header(default=None)): + """对该 job 的 order_item 全量跑对码,落 product_match。 + - 全部 EXACT/ALIAS(且不缺货源)→ 推进下一节点。 + - 有 MULTI/NONE → 逐条建 user_task,job 转 WAITING_USER(单条挂起,整批继续落库)。 + """ + _check_agent(x_agent_token) + if not _ENGINE_LOADED: + raise HTTPException(status_code=409, detail="对码引擎未装载货品主数据,先调 /api/match/reload-goods") + + conn = get_conn() + try: + conn.begin() + with conn.cursor() as cur: + job = _load_job(cur, job_id) + _guard_current_node(job, "PRODUCT_MATCH") + + cur.execute( + "SELECT id,raw_product_name,approval_no,spec,manufacturer FROM order_item" + " WHERE job_id=%s ORDER BY id", (job_id,), + ) + items = cur.fetchall() + if not items: + # 明确报错,不静默推进(否则等于跳过对码) + raise HTTPException(status_code=400, detail=f"job {job_id} 没有 order_item,无法对码") + + _load_aliases_into_engine(cur) + + # 对码结果是当前态,重跑时用 job_id+order_item_id upsert,避免重复 product_match/user_task。 + cur.execute( + "DELETE FROM user_task WHERE job_id=%s AND node_code IN ('PRODUCT_MATCH','SOURCE_ADD')" + " AND status='OPEN'", + (job_id,), + ) + + stat = {"EXACT": 0, "ALIAS": 0, "MULTI": 0, "NONE": 0, "SOURCE_ADD": 0} + need_match_human = False + for it in items: + r = _ENGINE.match_one(it["id"], it["raw_product_name"], it["approval_no"] or "", + it["spec"] or "", it["manufacturer"] or "") + stat[r.match_status] = stat.get(r.match_status, 0) + 1 + source_add_needed = _source_add_needed(cur, job, r.goods_id) + cur.execute( + "INSERT INTO product_match(job_id,order_item_id,match_key,match_status," + "goods_id,goods_code,base_unit,candidates_json,source_add_needed,created_at)" + " VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)" + " ON DUPLICATE KEY UPDATE match_key=VALUES(match_key),match_status=VALUES(match_status)," + "goods_id=VALUES(goods_id),goods_code=VALUES(goods_code),base_unit=VALUES(base_unit)," + "candidates_json=VALUES(candidates_json),source_add_needed=VALUES(source_add_needed)," + "confirmed_by=NULL,confirmed_at=NULL,id=LAST_INSERT_ID(id)", + (job_id, it["id"], r.match_key, r.match_status, r.goods_id, r.goods_code, + r.base_unit, + json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None, + source_add_needed, _now()), + ) + match_id = cur.lastrowid + if r.match_status in ("MULTI", "NONE"): + need_match_human = True + reason = "MATCH_MULTI" if r.match_status == "MULTI" else "MATCH_NONE" + cur.execute( + "INSERT INTO user_task(task_id,job_id,node_code,order_item_id,product_match_id," + "role_code,title,reason_type,candidates_json,status,created_at)" + " VALUES(%s,%s,'PRODUCT_MATCH',%s,%s,'PLANNER',%s,%s,%s,'OPEN',%s)", + (uuid.uuid4().hex, job_id, it["id"], match_id, + f"对码待确认:{it['raw_product_name']}", reason, + json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None, _now()), + ) + + source_add_candidates = _sync_source_add_task(cur, job) + stat["SOURCE_ADD"] = len(source_add_candidates) + + cur.execute( + "INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)" + " VALUES(%s,'PRODUCT_MATCH',%s,1,%s,%s,%s,%s)" + " ON DUPLICATE KEY UPDATE status=VALUES(status),ended_at=VALUES(ended_at)", + (job_id, NODE_EXECUTOR["PRODUCT_MATCH"], "WAITING_USER" if need_match_human else "SUCCESS", + _now(), _now(), _now()), + ) + _log_event(cur, job_id, "NODE_SUCCESS" if not need_match_human else "TASK_CREATED", + "PRODUCT_MATCH", "SYSTEM", None, stat) + + if need_match_human: + # 有待确认:job 转人工待办,不推进(人工 confirm 后再推进)。 + cur.execute( + "UPDATE job SET status='WAITING_USER',current_role='PLANNER',updated_at=%s WHERE job_id=%s", + (_now(), job_id), + ) + conn.commit() + return {"ok": True, "stat": stat, "waiting_user": True, + "source_add_needed": bool(source_add_candidates)} + + # 全部已明确匹配:按 needs_source_add 决定是否进入 SOURCE_ADD,否则进合同建单。 + job = _load_job(cur, job_id) + nxt = _advance(cur, job, "PRODUCT_MATCH") + conn.commit() + return {"ok": True, "stat": stat, "next_node": nxt, + "source_add_needed": bool(source_add_candidates)} + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +class MatchConfirmReq(BaseModel): + goods_id: str # 人工选定/新增的货品ID + goods_code: Optional[str] = None + base_unit: Optional[str] = None + confirmed_by: str = "planner" + save_as_alias: bool = True # 是否把这次选择沉淀成累积别名(下次自动命中) + + +@app.post("/api/match/{match_id}/confirm") +def match_confirm(match_id: int, req: MatchConfirmReq, + x_agent_token: Optional[str] = Header(default=None)): + """人工确认一条 MULTI/NONE 对码结果: + - 回写 product_match(goods_id/编码/单位、确认人)。 + - save_as_alias=1 时把 match_key→货品ID 沉淀进 mapping_config(PRODUCT),下次自动 ALIAS 命中。 + - 关掉对应 user_task;若该 job 已无待确认,推进 PRODUCT_MATCH 到下一节点。 + 注意:只写 mapping_config 累积别名,绝不回写 Oracle 货品主数据(主数据只读)。 + """ + _check_agent(x_agent_token) + conn = get_conn() + try: + conn.begin() + with conn.cursor() as cur: + cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,)) + pm = cur.fetchone() + if not pm: + raise HTTPException(status_code=404, detail="product_match not found") + job_id = pm["job_id"] + job = _load_job(cur, job_id) + source_add_needed = _source_add_needed(cur, job, req.goods_id) + + cur.execute( + "UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s," + "source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s", + (req.goods_id, req.goods_code, req.base_unit, source_add_needed, + req.confirmed_by, _now(), match_id), + ) + + if req.save_as_alias: + target = json.dumps({"goods_id": req.goods_id, "goods_code": req.goods_code, + "base_unit": req.base_unit}, ensure_ascii=False) + cur.execute( + "UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s" + " WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1", + (_now(), req.confirmed_by, _now(), pm["match_key"]), + ) + cur.execute( + "INSERT INTO mapping_config(config_type,config_key,target_value,is_active," + "effective_from,updated_by,updated_at)" + " VALUES('PRODUCT',%s,%s,1,%s,%s,%s)", + (pm["match_key"], target, _now(), req.confirmed_by, _now()), + ) + + # 精确关掉这条对码结果对应的待办(按 product_match_id 关联,不再靠"最早 OPEN"猜) + cur.execute( + "UPDATE user_task SET status='SUBMITTED',submitted_by=%s,submitted_at=%s" + " WHERE product_match_id=%s AND status='OPEN'", + (req.confirmed_by, _now(), match_id), + ) + _log_event(cur, job_id, "TASK_SUBMITTED", "PRODUCT_MATCH", "USER", req.confirmed_by, + {"match_id": match_id, "goods_id": req.goods_id, + "source_add_needed": bool(source_add_needed)}) + + source_add_candidates = _sync_source_add_task(cur, job) + + # 还有没有未解决的对码?(product_match 里仍是 MULTI/NONE 的) + cur.execute( + "SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')", + (job_id,), + ) + remaining = cur.fetchone()["c"] + if remaining == 0: + job = _load_job(cur, job_id) + if job.get("current_node_code") == "PRODUCT_MATCH": + cur.execute( + "UPDATE job_node SET status='SUCCESS',ended_at=%s" + " WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1", + (_now(), job_id), + ) + nxt = _advance(cur, job, "PRODUCT_MATCH") + conn.commit() + return {"ok": True, "all_resolved": True, "next_node": nxt, + "source_add_needed": bool(source_add_candidates)} + conn.commit() + return {"ok": True, "all_resolved": remaining == 0, "remaining": remaining, + "source_add_needed": bool(source_add_candidates)} + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +# ---------------- 人工待办提交(Web/H5 用) ---------------- + +class TaskSubmitReq(BaseModel): + action: str + payload: Optional[dict] = None + submitToken: str + operator: Optional[str] = "user" + + +def _submit_candidate_selection(cur, task: dict, job: dict, payload: dict, operator: str) -> dict: + """在当前事务/连接中完成一条对码候选确认。""" + match_id = payload.get("productMatchId") + goods_id = payload.get("goodsId") + if not match_id or not goods_id: + raise HTTPException( + status_code=400, + detail="选择候选货品时必须提供 productMatchId 和 goodsId", + ) + if task.get("node_code") != "PRODUCT_MATCH": + raise HTTPException(status_code=400, detail="该待办不是对码待办") + if task.get("product_match_id") and int(match_id) != int(task["product_match_id"]): + raise HTTPException(status_code=409, detail="候选货品与当前待办不匹配") + if job.get("current_node_code") != "PRODUCT_MATCH": + raise HTTPException(status_code=409, detail="当前单据已不在对码节点") + + cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,)) + pm = cur.fetchone() + if not pm or pm.get("job_id") != task["job_id"]: + raise HTTPException(status_code=404, detail="对码结果不存在") + + source_add_needed = _source_add_needed(cur, job, goods_id) + goods_code = payload.get("goodsCode") + base_unit = payload.get("baseUnit") + cur.execute( + "UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s," + "source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s", + (goods_id, goods_code, base_unit, source_add_needed, operator, _now(), match_id), + ) + + if payload.get("saveAsAlias", True): + target = json.dumps( + {"goods_id": goods_id, "goods_code": goods_code, "base_unit": base_unit}, + ensure_ascii=False, + ) + cur.execute( + "UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s" + " WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1", + (_now(), operator, _now(), pm["match_key"]), + ) + cur.execute( + "INSERT INTO mapping_config(config_type,config_key,target_value,is_active," + "effective_from,updated_by,updated_at) VALUES('PRODUCT',%s,%s,1,%s,%s,%s)", + (pm["match_key"], target, _now(), operator, _now()), + ) + + source_add_candidates = _sync_source_add_task(cur, job) + cur.execute( + "SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')", + (task["job_id"],), + ) + remaining = cur.fetchone()["c"] + result = { + "ok": True, + "all_resolved": remaining == 0, + "remaining": remaining, + "source_add_needed": bool(source_add_candidates), + } + if remaining == 0: + cur.execute( + "UPDATE job_node SET status='SUCCESS',ended_at=%s" + " WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1", + (_now(), task["job_id"]), + ) + result["next_node"] = _advance(cur, job, "PRODUCT_MATCH") + return result + + +@app.post("/api/tasks/{task_id}/submit") +def task_submit(task_id: str, req: TaskSubmitReq, + x_agent_token: Optional[str] = Header(default=None)): + _check_agent(x_agent_token) + conn = get_conn() + with conn.cursor() as cur: + cur.execute("SELECT * FROM user_task WHERE task_id=%s", (task_id,)) + task = cur.fetchone() + if not task: + raise HTTPException(status_code=404, detail="task not found") + + existing_token = task.get("submit_token") + if existing_token: + if existing_token != req.submitToken: + raise HTTPException(status_code=409, detail="task already submitted with another token") + try: + result = json.loads(task.get("submitted_result_json") or "{}") + except (TypeError, ValueError): + result = {"ok": True} + result["idempotent"] = True + return result + + if task.get("status") != "OPEN": + raise HTTPException(status_code=409, detail=f"task status is {task.get('status')}") + + job = _load_job(cur, task["job_id"]) + node_code = task["node_code"] + payload = req.payload or {} + + if req.action == "SELECT_CANDIDATE": + result = _submit_candidate_selection(cur, task, job, payload, req.operator or "user") + elif req.action == "CONFIRM_AND_CONTINUE": + if job.get("current_node_code") != node_code: + raise HTTPException( + status_code=409, + detail=f"job 当前在 {job.get('current_node_code')},不能提交 {node_code}", + ) + + if node_code == "M5_RECEIPT_NOTICE": + receipt_no = payload.get("receiptNoticeNo") + if not receipt_no: + raise HTTPException(status_code=400, detail="M5_RECEIPT_NOTICE 需要 receiptNoticeNo") + cur.execute( + "UPDATE job SET receipt_notice_no=%s,updated_at=%s WHERE job_id=%s", + (receipt_no, _now(), task["job_id"]), + ) + + if node_code == "SOURCE_ADD": + cur.execute( + "UPDATE job SET needs_source_add=0,updated_at=%s WHERE job_id=%s", + (_now(), task["job_id"]), + ) + + _mark_node_success(cur, task["job_id"], node_code) + job = _load_job(cur, task["job_id"]) + nxt = _advance(cur, job, node_code) + result = {"ok": True, "next_node": nxt} + elif req.action == "TERMINATE_JOB": + cur.execute( + "UPDATE job SET status='CANCELLED',finished_at=%s,updated_at=%s WHERE job_id=%s", + (_now(), _now(), task["job_id"]), + ) + _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, + {"action": req.action, "payload": payload}) + result = {"ok": True, "job_status": "CANCELLED"} + elif req.action in ("TRANSFER_MANUAL", "RETRY_CURRENT_NODE"): + if req.action == "TRANSFER_MANUAL": + target_role = payload.get("targetRole") + if target_role: + cur.execute( + "UPDATE job SET current_role=%s,updated_at=%s WHERE job_id=%s", + (target_role, _now(), task["job_id"]), + ) + _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, + {"action": req.action, "payload": payload}) + result = {"ok": True, "job_status": job.get("status"), "node_code": node_code} + else: + raise HTTPException(status_code=400, detail=f"unsupported task action {req.action}") + + result_json = json.dumps(result, ensure_ascii=False) + cur.execute( + "UPDATE user_task SET status='SUBMITTED',submit_token=%s,submitted_action=%s," + "submitted_payload_json=%s,submitted_result_json=%s,submitted_by=%s,submitted_at=%s" + " WHERE task_id=%s", + ( + req.submitToken, + req.action, + json.dumps(payload, ensure_ascii=False), + result_json, + req.operator, + _now(), + task_id, + ), + ) + _log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator, + {"action": req.action, "payload": payload, "result": result}) + return result + + +# ---------------- 整单查询(前端画树状图用) ---------------- + +@app.get("/api/jobs/{job_id}") +def get_job(job_id: str): + conn = get_conn() + with conn.cursor() as cur: + cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) + job = cur.fetchone() + if not job: + raise HTTPException(status_code=404, detail="job not found") + cur.execute("SELECT node_code,attempt_no,status,started_at,ended_at,duration_ms," + "error_code,error_summary,output_json FROM job_node WHERE job_id=%s" + " ORDER BY id", (job_id,)) + nodes = cur.fetchall() + cur.execute("SELECT task_id,node_code,role_code,reason_type,status FROM user_task" + " WHERE job_id=%s ORDER BY id", (job_id,)) + tasks = cur.fetchall() + artifacts = _fetch_job_artifacts(cur, job_id) + return {"job": job, "nodes": nodes, "tasks": tasks, "artifacts": artifacts, + "node_order": NODE_ORDER, "node_executor": NODE_EXECUTOR, + "conditional_nodes": sorted(CONDITIONAL_NODES)} + + +@app.get("/api/jobs/{job_id}/timeline") +def get_job_timeline(job_id: str): + conn = get_conn() + with conn.cursor() as cur: + cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,)) + job = cur.fetchone() + if not job: + raise HTTPException(status_code=404, detail="job not found") + cur.execute( + "SELECT node_code,attempt_no,status,executor_type,started_at,ended_at " + "FROM job_node WHERE job_id=%s ORDER BY id", + (job_id,), + ) + nodes = cur.fetchall() + return {"job": job, "timeline": build_timeline(job, nodes)} + + +@app.get("/api/jobs/{job_id}/evidence-package") +def get_evidence_package(job_id: str): + conn = get_conn() + with conn.cursor() as cur: + cur.execute("SELECT job_id FROM job WHERE job_id=%s", (job_id,)) + job = cur.fetchone() + if not job: + raise HTTPException(status_code=404, detail="job not found") + artifacts = _fetch_job_artifacts(cur, job_id) + return {"job_id": job_id, "artifacts": artifacts} + + +@app.get("/health") +def health(): + conn = None + try: + conn = get_conn() + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + return {"ok": True, "database": "ok"} + except Exception as exc: + raise HTTPException(status_code=503, detail=f"database unavailable: {exc}") from exc + finally: + if conn is not None: + conn.close() + + +@app.get("/api/state-machine") +def get_state_machine(): + return build_state_machine_meta() diff --git a/state_center/app/match_engine.py b/state_center/app/match_engine.py new file mode 100644 index 0000000..171e169 --- /dev/null +++ b/state_center/app/match_engine.py @@ -0,0 +1,215 @@ +""" +对码引擎(核心难点)。 + +数据源: + - 货品主数据在 Oracle(只读)。开工时可全量 load 进内存,后续内存匹配。 + - mapping_config(PRODUCT):平台奇葩写法→货品ID 的人工确认累积,存在自己 MySQL。 + +查找顺序(命中率越用越高): + 1. 先查 mapping_config 累积别名 → 命中直接出货品ID(ALIAS,累积别名命中,非人工介入) + 2. 再查货品主数据(内存/Oracle)四要素精确键 → EXACT + 3. 规格/厂家容错后同商品名多条 → MULTI(出候选) + 4. 都没有 → NONE(转人工) + +对码结果状态只有 4 种:EXACT / ALIAS / MULTI / NONE。 +(无 FUZZY:本引擎不产出模糊命中档,拿不准就走 MULTI 出候选让人选。) +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + + +# ---------- 规范化:对付"15×2 vs 30片""干一瓶 vs ×1瓶""进口英文厂家" ---------- + +_FULL2HALF = {ord(" "): " ", ord("("): "(", ord(")"): ")", ord("*"): "*", ord("×"): "*"} + + +def _norm_text(s: Optional[str]) -> str: + """通用规范化:去空格、全半角、大小写、常见标点。""" + if not s: + return "" + s = s.translate(_FULL2HALF) + s = s.lower().strip() + s = re.sub(r"[\s,,。.、;;::'\"“”‘’]+", "", s) + return s + + +def _norm_manufacturer(s: Optional[str]) -> str: + """厂家名容错:进口药英文名统一大小写、去标点(B.V. / S.r.l. 之类)。""" + if not s: + return "" + s = s.lower().strip() + s = re.sub(r"[.\s,]+", "", s) # 去点、空格、逗号 + return s + + +def _norm_spec(s: Optional[str]) -> str: + """ + 规格标准化。把 '15×2' '15*2' 展开成乘积;'30片' '×1瓶' 抽出数字。 + 返回一个可比较的规范串,如 '30' 或 '30片'。 + 这是痛点,规则先覆盖会议里点名的几种,后续可加。 + """ + if not s: + return "" + raw = s.translate(_FULL2HALF).lower().strip() + raw = re.sub(r"\s+", "", raw) + + # 形如 15*2 / 15x2 → 乘积 30 + m = re.search(r"(\d+(?:\.\d+)?)\*(\d+(?:\.\d+)?)", raw) + if m: + product = float(m.group(1)) * float(m.group(2)) + product = int(product) if product == int(product) else product + # 保留单位后缀(如 片/瓶),便于跟 '30片' 对齐 + unit = re.sub(r"[\d*.]+", "", raw) + return f"{product}{unit}" + + return raw + + +def build_match_key(common_name: str, approval_no: str, spec: str, manufacturer: str) -> str: + """四要素拼接键:通用名 + 批准文号 + 规格 + 厂家(全部规范化)。""" + return "|".join([ + _norm_text(common_name), + _norm_text(approval_no), + _norm_spec(spec), + _norm_manufacturer(manufacturer), + ]) + + +# ---------- 货品主数据(Oracle 镜像到内存) ---------- + +@dataclass +class GoodsRecord: + common_name: str + approval_no: str + spec: str + base_unit: str + manufacturer: str + goods_id: str + goods_code: str + + +@dataclass +class MatchResult: + order_item_id: int + match_key: str + match_status: str # EXACT / ALIAS / MULTI / NONE + goods_id: Optional[str] = None + goods_code: Optional[str] = None + base_unit: Optional[str] = None + candidates: list = field(default_factory=list) + + +class MatchEngine: + """ + 内存索引 + 两级查找。 + goods_index: 四要素键 -> [GoodsRecord] (可能一键多货品,即一对多) + name_index: 规范通用名 -> [GoodsRecord] (用于同商品名多规格出候选) + alias_map: mapping_config 累积别名 config_key -> (goods_id, goods_code, base_unit) + """ + + def __init__(self): + self.goods_index: dict[str, list[GoodsRecord]] = {} + self.name_index: dict[str, list[GoodsRecord]] = {} + self.alias_map: dict[str, tuple[str, str, str]] = {} + + # -- 装载 -- + def load_goods(self, records: list[GoodsRecord]) -> None: + self.goods_index.clear() + self.name_index.clear() + for r in records: + key = build_match_key(r.common_name, r.approval_no, r.spec, r.manufacturer) + self.goods_index.setdefault(key, []).append(r) + self.name_index.setdefault(_norm_text(r.common_name), []).append(r) + + def load_aliases(self, aliases: dict[str, tuple[str, str, str]]) -> None: + """aliases: config_key -> (goods_id, goods_code, base_unit)""" + self.alias_map = dict(aliases) + + # -- 单行对码 -- + def match_one(self, order_item_id: int, common_name: str, approval_no: str, + spec: str, manufacturer: str) -> MatchResult: + key = build_match_key(common_name, approval_no, spec, manufacturer) + + # 1) 累积别名优先(ALIAS = 历史人工确认沉淀的别名命中,不是当次人工介入) + if key in self.alias_map: + gid, gcode, unit = self.alias_map[key] + return MatchResult(order_item_id, key, "ALIAS", gid, gcode, unit) + + # 2) 四要素精确 + hits = self.goods_index.get(key, []) + if len(hits) == 1: + r = hits[0] + return MatchResult(order_item_id, key, "EXACT", r.goods_id, r.goods_code, r.base_unit) + if len(hits) > 1: + return MatchResult(order_item_id, key, "MULTI", + candidates=[self._cand(r) for r in hits]) + + # 3) 同商品名多规格 → 出候选给人工选 + name_hits = self.name_index.get(_norm_text(common_name), []) + if name_hits: + return MatchResult(order_item_id, key, "MULTI", + candidates=[self._cand(r) for r in name_hits]) + + # 4) 无命中 + return MatchResult(order_item_id, key, "NONE") + + @staticmethod + def _cand(r: GoodsRecord) -> dict: + return { + "goods_id": r.goods_id, + "goods_code": r.goods_code, + "common_name": r.common_name, + "spec": r.spec, + "base_unit": r.base_unit, + "manufacturer": r.manufacturer, + "approval_no": r.approval_no, + } + + +# ---------- Oracle 取数(拿到真实连接串后启用) ---------- + +def load_goods_from_oracle(settings) -> list[GoodsRecord]: + """ + 从 Oracle 只读拉全量货品主数据。 + 需要: pip install oracledb + 真实表名/列名在 config.py 里改。 + """ + import oracledb # 延迟导入,没配 Oracle 时不影响其他功能 + + sql = f""" + SELECT {settings.ORACLE_COL_COMMON_NAME}, + {settings.ORACLE_COL_APPROVAL_NO}, + {settings.ORACLE_COL_SPEC}, + {settings.ORACLE_COL_UNIT}, + {settings.ORACLE_COL_MANUFACTURER}, + {settings.ORACLE_COL_GOODS_ID}, + {settings.ORACLE_COL_GOODS_CODE} + FROM {settings.ORACLE_GOODS_TABLE} + """ + conn = oracledb.connect( + user=settings.ORACLE_USER, + password=settings.ORACLE_PASSWORD, + dsn=settings.ORACLE_DSN, + ) + try: + cur = conn.cursor() + cur.execute(sql) + rows = cur.fetchall() + finally: + conn.close() + + out = [] + for row in rows: + out.append(GoodsRecord( + common_name=str(row[0] or ""), + approval_no=str(row[1] or ""), + spec=str(row[2] or ""), + base_unit=str(row[3] or ""), + manufacturer=str(row[4] or ""), + goods_id=str(row[5] or ""), + goods_code=str(row[6] or ""), + )) + return out diff --git a/state_center/app/web_state_machine.py b/state_center/app/web_state_machine.py new file mode 100644 index 0000000..a475a73 --- /dev/null +++ b/state_center/app/web_state_machine.py @@ -0,0 +1,163 @@ +"""Web 版状态机纯逻辑。 + +只放和 UI/接口共享的计算,不碰数据库。 +""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +NODE_ORDER = [ + "ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD", + "CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER", + "REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER", + "M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB", +] + +TRANSITIONS = { + "ORDER_FETCH": "OCR_EXTRACT", + "OCR_EXTRACT": "PRODUCT_MATCH", + "PRODUCT_MATCH": "SOURCE_ADD", + "SOURCE_ADD": "CONTRACT_CREATE", + "CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT", + "CONTRACT_APPROVE_WAIT": "REBATE_HEADER", + "REBATE_HEADER": "REBATE_IMPORT", + "REBATE_IMPORT": "REBATE_SUBMIT", + "REBATE_SUBMIT": "M5_CREATE_ORDER", + "M5_CREATE_ORDER": "M5_MANUAL_CONFIRM", + "M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE", + "M5_RECEIPT_NOTICE": "ARCHIVE_JOB", + "ARCHIVE_JOB": None, +} + +CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"} +HUMAN_NODE_ROLES = { + "SOURCE_ADD": "PURCHASER", + "M5_MANUAL_CONFIRM": "PURCHASER", + "M5_RECEIPT_NOTICE": "PURCHASER", +} + +NODE_EXECUTOR = { + "ORDER_FETCH": "RPA", + "OCR_EXTRACT": "RPA", + "PRODUCT_MATCH": "STATE_CENTER", + "SOURCE_ADD": "HUMAN", + "CONTRACT_CREATE": "RPA", + "CONTRACT_APPROVE_WAIT": "RPA", + "REBATE_HEADER": "RPA", + "REBATE_IMPORT": "RPA", + "REBATE_SUBMIT": "RPA", + "M5_CREATE_ORDER": "RPA", + "M5_MANUAL_CONFIRM": "HUMAN", + "M5_RECEIPT_NOTICE": "HUMAN", + "ARCHIVE_JOB": "STATE_CENTER", +} + +NODE_LABELS_ZH = { + "ORDER_FETCH": "订单获取", + "OCR_EXTRACT": "OCR识别", + "PRODUCT_MATCH": "对码", + "SOURCE_ADD": "新增货源", + "CONTRACT_CREATE": "合同建单", + "CONTRACT_APPROVE_WAIT": "合同审批等待", + "REBATE_HEADER": "返利总单", + "REBATE_IMPORT": "返利细单导入", + "REBATE_SUBMIT": "返利送审", + "M5_CREATE_ORDER": "M5建单", + "M5_MANUAL_CONFIRM": "M5人工确认", + "M5_RECEIPT_NOTICE": "收货通知单", + "ARCHIVE_JOB": "归档", +} + +EXECUTOR_LABELS_ZH = { + "STATE_CENTER": "状态中心", + "RPA": "艺赛旗执行", + "HUMAN": "人工处理", +} + +STATUS_LABELS_ZH = { + "PENDING": "待处理", + "RUNNING": "执行中", + "SUCCESS": "已完成", + "FAILED": "已失败", + "WAITING_USER": "等待人工", + "WAITING_EXTERNAL": "等待外部", + "SKIPPED": "已跳过", +} + + +def _should_skip(node_code: str, job: Mapping[str, object]) -> bool: + if node_code == "OCR_EXTRACT": + return job.get("source_type") != "OFFLINE" + if node_code == "SOURCE_ADD": + return not job.get("needs_source_add") + return False + + +def _node_row_map(job_nodes: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]: + out: dict[str, Mapping[str, object]] = {} + for row in job_nodes: + node_code = str(row.get("node_code") or "") + if node_code and node_code not in out: + out[node_code] = row + return out + + +def _display_status_for_node(job: Mapping[str, object], node_code: str, + job_nodes_by_code: Mapping[str, Mapping[str, object]]) -> str: + row = job_nodes_by_code.get(node_code) + if row: + row_status = str(row.get("status") or "PENDING") + if row_status in {"SUCCESS", "FAILED", "RUNNING", "WAITING_USER", "WAITING_EXTERNAL"}: + return row_status + return "PENDING" + + if node_code == job.get("current_node_code"): + job_status = str(job.get("status") or "PENDING") + if job_status in {"RUNNING", "WAITING_USER", "WAITING_EXTERNAL", "FAILED_RETRYABLE", "FAILED_MANUAL"}: + return "WAITING_USER" if job_status == "WAITING_USER" else ( + "WAITING_EXTERNAL" if job_status == "WAITING_EXTERNAL" else ( + "RUNNING" if job_status == "RUNNING" else "FAILED" + ) + ) + if job_status == "SUCCESS": + return "SUCCESS" + return "PENDING" + + if node_code in CONDITIONAL_NODES and _should_skip(node_code, job): + return "SKIPPED" + + return "PENDING" + + +def build_timeline(job: Mapping[str, object], job_nodes: Sequence[Mapping[str, object]]) -> list[dict]: + """Return all nodes with backend-computed display status. + + The frontend should render this directly and never infer node skipping itself. + """ + by_code = _node_row_map(job_nodes) + timeline = [] + for node_code in NODE_ORDER: + row = by_code.get(node_code) + timeline.append({ + "node_code": node_code, + "display_status": _display_status_for_node(job, node_code, by_code), + "attempt_no": row.get("attempt_no") if row else None, + "executor_type": row.get("executor_type") if row else None, + "status": row.get("status") if row else None, + "started_at": row.get("started_at") if row else None, + "ended_at": row.get("ended_at") if row else None, + }) + return timeline + + +def build_state_machine_meta() -> dict: + return { + "node_order": NODE_ORDER, + "transitions": TRANSITIONS, + "conditional_nodes": sorted(CONDITIONAL_NODES), + "human_node_roles": HUMAN_NODE_ROLES, + "node_executor": NODE_EXECUTOR, + "node_labels": NODE_LABELS_ZH, + "executor_labels": EXECUTOR_LABELS_ZH, + "status_labels": STATUS_LABELS_ZH, + } diff --git a/state_center/requirements.txt b/state_center/requirements.txt new file mode 100644 index 0000000..2e5826c --- /dev/null +++ b/state_center/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +pydantic==2.9.2 +pymysql==1.1.1 +cryptography==50.0.0 +# 对码接 Oracle 时再装(拿到连接串后): +# oracledb==2.4.1 diff --git a/state_center/sql/schema.sql b/state_center/sql/schema.sql new file mode 100644 index 0000000..5596445 --- /dev/null +++ b/state_center/sql/schema.sql @@ -0,0 +1,205 @@ +-- 采购主链 RPA 一期 · 状态中心建库脚本 +-- 库里只存"流程状态 + 对码累积别名 + 证据",不存对码表本身(对码表在 Oracle,直连查)。 +-- 目标库:MySQL 8 + +CREATE DATABASE IF NOT EXISTS rpa_center DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE rpa_center; + +-- ============================================================ +-- job:一次流程实例 = 同供应商 + 同订单日期 + 同来源 = 一条合同 +-- ============================================================ +CREATE TABLE IF NOT EXISTS job ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL UNIQUE COMMENT '对外流程单号', + business_key VARCHAR(160) NOT NULL COMMENT '来源+供应商+订单日期,幂等用', + parent_job_id VARCHAR(64) NULL COMMENT '重跑/加单来源', + source_type VARCHAR(16) NOT NULL COMMENT 'YYC医药城/XYY小药药/OFFLINE线下单', + order_date DATE NOT NULL COMMENT '订单日期=合同签约日期', + supplier_name VARCHAR(128) NOT NULL COMMENT '供应商/商家', + order_no_list_json JSON NULL COMMENT '该批订单号数组', + contract_template VARCHAR(16) NULL COMMENT 'FORMAT格式/NON_FORMAT非格式承兑', + need_pdf_upload TINYINT NOT NULL DEFAULT 0 COMMENT '医药城=1,小药药=0', + planner_name VARCHAR(64) NULL COMMENT '计划员', + current_node_code VARCHAR(64) NOT NULL COMMENT '当前节点', + status VARCHAR(24) NOT NULL COMMENT 'PENDING/RUNNING/WAITING_USER/WAITING_EXTERNAL/SUCCESS/FAILED_RETRYABLE/FAILED_MANUAL/CANCELLED', + mapping_version VARCHAR(64) NULL COMMENT '绑定的对码累积版本', + needs_source_add TINYINT NOT NULL DEFAULT 0 COMMENT '对码后判定是否缺货源,决定是否走 SOURCE_ADD 条件节点', + executor_platform VARCHAR(16) NULL COMMENT '默认执行平台,如 ISRPA', + contract_system_no VARCHAR(64) NULL COMMENT '合同系统编号(钥匙1)', + rebate_order_no VARCHAR(64) NULL COMMENT '返利中心总单号', + m5_order_no VARCHAR(64) NULL COMMENT 'M5采购订单号', + receipt_notice_no VARCHAR(64) NULL COMMENT '收货通知单号', + current_role VARCHAR(16) NULL COMMENT '当前责任角色', + current_assignee VARCHAR(64) NULL COMMENT '当前责任人', + auto_save_enabled TINYINT NOT NULL DEFAULT 0 COMMENT 'M5自动保存开关,一期=0', + next_check_at DATETIME NULL COMMENT '等待态(如合同审批)下次检查时间,调度器扫这个', + wait_check_count INT NOT NULL DEFAULT 0 COMMENT '当前等待态已检查次数,超上限升级OPS', + created_by VARCHAR(64) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + finished_at DATETIME NULL, + KEY idx_business_status (business_key, status), + KEY idx_supplier_date (supplier_name, order_date), + KEY idx_status (status), + KEY idx_next_check (status, next_check_at), + KEY idx_created_at (created_at) +) COMMENT='流程实例主表'; + +-- ============================================================ +-- order_item:订单明细行(对码前的原始数据) +-- ============================================================ +CREATE TABLE IF NOT EXISTS order_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL, + order_no VARCHAR(64) NOT NULL COMMENT '订单号(贯穿追溯)', + parent_order_no VARCHAR(64) NULL COMMENT '父订单号(加单/拆单)', + raw_product_name VARCHAR(255) NOT NULL COMMENT '平台品名', + platform_product_code VARCHAR(64) NULL COMMENT '平台产品编码', + spec VARCHAR(128) NULL COMMENT '规格(对码痛点:15×2 vs 30片)', + manufacturer VARCHAR(255) NULL COMMENT '厂家(含进口英文)', + approval_no VARCHAR(64) NULL COMMENT '国药准字/批准文号', + qty DECIMAL(14,2) NOT NULL COMMENT '数量', + unit_price DECIMAL(14,4) NULL COMMENT '单价', + source_row_no INT NULL COMMENT '原始Excel行号', + created_at DATETIME NOT NULL, + KEY idx_job (job_id), + KEY idx_order_no (order_no) +) COMMENT='订单明细行'; + +-- ============================================================ +-- product_match:对码结果(核心难点落库) +-- ============================================================ +CREATE TABLE IF NOT EXISTS product_match ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL, + order_item_id BIGINT NOT NULL COMMENT '对应订单行', + match_key VARCHAR(512) NOT NULL COMMENT '四要素拼接键:通用名+批准文号+规格+厂家', + match_status VARCHAR(16) NOT NULL COMMENT 'EXACT精确/ALIAS别名命中/MULTI一对多/NONE未命中(无FUZZY)', + goods_id VARCHAR(64) NULL COMMENT '命中的M5货品ID(来自Oracle)', + goods_code VARCHAR(64) NULL COMMENT '货品编码', + base_unit VARCHAR(32) NULL COMMENT '基本单位', + candidates_json JSON NULL COMMENT '一对多候选列表(供H5选择)', + source_add_needed TINYINT NOT NULL DEFAULT 0 COMMENT '是否需M5新增货源', + confirmed_by VARCHAR(64) NULL COMMENT '人工确认人', + confirmed_at DATETIME NULL, + mapping_version VARCHAR(64) NULL COMMENT '使用的对码累积版本', + created_at DATETIME NOT NULL, + UNIQUE KEY uniq_job_order_item (job_id, order_item_id), + KEY idx_job (job_id), + KEY idx_status (match_status) +) COMMENT='对码结果'; + +-- ============================================================ +-- job_node:节点执行(每节点每次尝试一行,不覆盖历史) +-- ============================================================ +CREATE TABLE IF NOT EXISTS job_node ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL, + node_code VARCHAR(64) NOT NULL COMMENT 'ORDER_FETCH/PRODUCT_MATCH/...', + executor_type VARCHAR(16) NOT NULL DEFAULT 'RPA' COMMENT '执行方:STATE_CENTER编排/RPA执行/HUMAN人工挂起', + attempt_no INT NOT NULL DEFAULT 1 COMMENT '第几次尝试', + status VARCHAR(24) NOT NULL COMMENT '节点状态', + executor_platform VARCHAR(16) NULL COMMENT '实际执行平台', + executor_run_id VARCHAR(64) NULL COMMENT '平台侧任务号', + started_at DATETIME NULL, + ended_at DATETIME NULL, + duration_ms BIGINT NULL, + error_code VARCHAR(64) NULL, + error_summary VARCHAR(500) NULL, + role_at_failure VARCHAR(16) NULL, + output_json JSON NULL COMMENT '节点输出(业务编号等)', + callback_idempotency_key VARCHAR(128) NULL COMMENT '回调幂等键', + created_at DATETIME NOT NULL, + UNIQUE KEY uniq_job_node_attempt (job_id, node_code, attempt_no), + KEY idx_status (status) +) COMMENT='节点执行记录'; + +-- ============================================================ +-- user_task:人工待办 +-- ============================================================ +CREATE TABLE IF NOT EXISTS user_task ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_id VARCHAR(64) NOT NULL UNIQUE, + job_id VARCHAR(64) NOT NULL, + node_code VARCHAR(64) NOT NULL, + order_item_id BIGINT NULL COMMENT '对应订单行(对码类待办用)', + product_match_id BIGINT NULL COMMENT '对应对码结果行(对码类待办精确关联,confirm 时按它关闭)', + role_code VARCHAR(16) NOT NULL COMMENT 'PLANNER/PURCHASER/QC/OPS', + assignee_user_id VARCHAR(64) NULL, + title VARCHAR(255) NOT NULL, + reason_type VARCHAR(32) NOT NULL COMMENT 'MATCH_MULTI/MATCH_NONE/SOURCE_ADD/OCR_LOW_CONF/REBATE_DUP/QC_MISSING/CONTRACT_DISABLED', + reason_user_friendly VARCHAR(500) NULL COMMENT '人话说明', + why_me_text VARCHAR(255) NULL COMMENT '为什么派给你', + editable_fields_json JSON NULL COMMENT '白名单字段', + action_schema_json JSON NULL COMMENT '允许动作', + comparison_snapshot_json JSON NULL COMMENT '原值/建议/当前对比', + candidates_json JSON NULL COMMENT '一对多对码候选', + status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/SUBMITTED/CANCELLED/EXPIRED', + submit_token VARCHAR(64) NULL COMMENT '防重复提交', + submitted_action VARCHAR(24) NULL, + submitted_payload_json JSON NULL, + submitted_result_json JSON NULL COMMENT '提交结果,同 submit_token 重放时返回', + submitted_by VARCHAR(64) NULL, + submitted_at DATETIME NULL, + due_at DATETIME NULL COMMENT '超时升级', + created_at DATETIME NOT NULL, + KEY idx_job (job_id), + KEY idx_role_status (role_code, status), + KEY idx_product_match (product_match_id) +) COMMENT='人工待办'; + +-- ============================================================ +-- mapping_config:对码累积别名 + 供应商对照等映射(版本化) +-- 注意:这不是对码表的拷贝!对码表在 Oracle。 +-- 这里存的是"平台奇葩写法 → 货品ID"的人工确认记录,以及供应商对照。 +-- ============================================================ +CREATE TABLE IF NOT EXISTS mapping_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + config_type VARCHAR(24) NOT NULL COMMENT 'PRODUCT对码别名/SUPPLIER供应商对照/SETTLEMENT/PAYMENT_TERM/PLANNER_ROUTE', + config_key VARCHAR(512) NOT NULL COMMENT '对码时=四要素拼接键或平台写法', + source_value VARCHAR(512) NULL COMMENT '来源值', + target_value VARCHAR(512) NULL COMMENT '目标值(对码=货品ID+编码+单位JSON)', + extra_json JSON NULL, + version_no VARCHAR(64) NULL COMMENT '版本号', + is_active TINYINT NOT NULL DEFAULT 1, + effective_from DATETIME NULL, + effective_to DATETIME NULL, + updated_by VARCHAR(64) NULL, + updated_at DATETIME NOT NULL, + KEY idx_type_key (config_type, config_key), + KEY idx_type_key_active (config_type, config_key, is_active, updated_at, id), + KEY idx_active (is_active) +) COMMENT='映射/对码累积别名(非对码表拷贝)'; + +-- ============================================================ +-- job_artifact:证据元数据(文件落对象存储,这里存元数据) +-- ============================================================ +CREATE TABLE IF NOT EXISTS job_artifact ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL, + node_code VARCHAR(64) NULL, + attempt_no INT NULL, + artifact_type VARCHAR(32) NOT NULL COMMENT 'SOURCE_EXCEL/SOURCE_IMAGE/OCR_JSON/CONTRACT_PDF/MERGED_PDF/SCREENSHOT/TEMPLATE/LOG', + storage_path VARCHAR(500) NOT NULL, + file_hash VARCHAR(128) NULL, + visible_roles VARCHAR(128) NULL COMMENT '可见角色', + is_masked TINYINT NOT NULL DEFAULT 0 COMMENT '脱敏标记', + created_at DATETIME NOT NULL, + KEY idx_job (job_id) +) COMMENT='证据元数据'; + +-- ============================================================ +-- job_event:事件流(审计留痕) +-- ============================================================ +CREATE TABLE IF NOT EXISTS job_event ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + job_id VARCHAR(64) NOT NULL, + event_type VARCHAR(32) NOT NULL COMMENT 'JOB_CREATED/NODE_STARTED/NODE_SUCCESS/NODE_FAILED/TASK_CREATED/TASK_SUBMITTED/MANUAL_HANDOFF/JOB_FINISHED', + node_code VARCHAR(64) NULL, + operator_type VARCHAR(16) NOT NULL COMMENT 'SYSTEM/ROBOT/USER', + operator VARCHAR(64) NULL, + detail_json JSON NULL, + created_at DATETIME NOT NULL, + KEY idx_job (job_id), + KEY idx_type (event_type) +) COMMENT='事件流'; diff --git a/state_center/tests/test_dev_launcher.py b/state_center/tests/test_dev_launcher.py new file mode 100644 index 0000000..a7b6d80 --- /dev/null +++ b/state_center/tests/test_dev_launcher.py @@ -0,0 +1,111 @@ +from scripts import dev + + +def test_backend_runtime_probe_checks_mysql8_auth_dependency(monkeypatch): + commands = [] + + monkeypatch.setattr(dev, "_backend_python", lambda: "/tmp/backend-python") + + def fake_run(command, **kwargs): + commands.append(command) + + class Result: + returncode = 0 + stderr = "" + + return Result() + + monkeypatch.setattr(dev.subprocess, "run", fake_run) + + assert dev._ensure_backend_runtime() == "/tmp/backend-python" + assert commands[0] == [ + "/tmp/backend-python", + "-c", + "import fastapi, pymysql, uvicorn, cryptography", + ] + + +def test_backend_environment_has_local_defaults(monkeypatch): + for name in ("DEV_MODE", "MYSQL_DSN", "AGENT_TOKEN"): + monkeypatch.delenv(name, raising=False) + + env = dev.build_backend_env({}) + + assert env["DEV_MODE"] == "true" + assert env["MYSQL_DSN"] == "mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4" + assert env["AGENT_TOKEN"] == "dev-agent-token" + + +def test_start_uses_checked_backend_python(monkeypatch): + spawned = [] + + monkeypatch.setattr(dev, "ensure_mysql", lambda: None) + monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None) + monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python") + monkeypatch.setattr(dev, "_read_pids", lambda: {}) + monkeypatch.setattr(dev, "_pid_alive", lambda pid: False) + monkeypatch.setattr(dev, "_port_open", lambda host, port: False) + monkeypatch.setattr(dev, "_write_pids", lambda pids: None) + monkeypatch.setattr(dev, "build_backend_env", lambda: {}) + + def fake_spawn(name, command, cwd, env): + spawned.append((name, command)) + return len(spawned) + + monkeypatch.setattr(dev, "_spawn", fake_spawn) + + dev.start() + + assert spawned[0] == ( + "backend", + [ + "/tmp/backend-python", + "-m", + "uvicorn", + "state_center.app.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ], + ) + + +def test_start_reuses_frontend_when_port_is_already_open(monkeypatch): + spawned = [] + + monkeypatch.setattr(dev, "ensure_mysql", lambda: None) + monkeypatch.setattr(dev, "wait_for_port", lambda host, port: None) + monkeypatch.setattr(dev, "_ensure_backend_runtime", lambda: "/tmp/backend-python") + monkeypatch.setattr(dev, "_read_pids", lambda: {}) + monkeypatch.setattr(dev, "_write_pids", lambda pids: None) + monkeypatch.setattr(dev, "build_backend_env", lambda: {}) + monkeypatch.setattr(dev, "_port_open", lambda host, port: port == 5173) + + def fake_spawn(name, command, cwd, env): + spawned.append(name) + return len(spawned) + + monkeypatch.setattr(dev, "_spawn", fake_spawn) + + dev.start() + + assert spawned == ["backend"] + + +def test_pid_alive_rejects_invalid_pid(): + assert dev._pid_alive(-1) is False + assert dev._pid_alive(0) is False + + +def test_stop_pid_terminates_process_group_on_posix(monkeypatch): + calls = [] + + monkeypatch.setattr(dev.os, "name", "posix") + monkeypatch.setattr(dev.os, "getpgid", lambda pid: 4321) + monkeypatch.setattr(dev.os, "killpg", lambda pgid, sig: calls.append(("killpg", pgid, sig))) + monkeypatch.setattr(dev.os, "kill", lambda pid, sig: calls.append(("kill", pid, sig))) + + dev._stop_pid(1234) + + assert calls == [("killpg", 4321, dev.signal.SIGTERM)] diff --git a/state_center/tests/test_match_engine.py b/state_center/tests/test_match_engine.py new file mode 100644 index 0000000..d796460 --- /dev/null +++ b/state_center/tests/test_match_engine.py @@ -0,0 +1,81 @@ +"""对码引擎单测:不依赖 DB / Oracle,纯内存验证四要素匹配 + 规格标准化。 +运行: python -m pytest state_center/tests/test_match_engine.py -v +或直接: python state_center/tests/test_match_engine.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from state_center.app.match_engine import ( # noqa: E402 + MatchEngine, GoodsRecord, build_match_key, _norm_spec, +) + + +def _engine(): + eng = MatchEngine() + eng.load_goods([ + GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*30", "盒", "拜耳医药", "G001", "C001"), + GoodsRecord("哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "盒", "哈药集团", "G002", "C002"), + # 同名多规格(用于一对多) + GoodsRecord("阿卡波糖片", "国药准字H19990001", "50mg*60", "盒", "拜耳医药", "G003", "C003"), + ]) + return eng + + +def test_spec_normalize_product(): + # 15×2 展开成 30 + assert _norm_spec("15×2") == _norm_spec("15*2") + assert _norm_spec("50mg*30").startswith("1500") or "mg" in _norm_spec("50mg*30") + + +def test_exact_hit(): + eng = _engine() + r = eng.match_one(1, "哈药牌钙铁锌口服液", "国药准字Z20000002", "10ml*10", "哈药集团") + assert r.match_status == "EXACT" + assert r.goods_id == "G002" + + +def test_multi_same_name(): + eng = _engine() + # 规格写法对不上任何精确键,但商品名命中多条 → MULTI + r = eng.match_one(2, "阿卡波糖片", "国药准字H19990001", "写个对不上的规格", "拜耳医药") + assert r.match_status == "MULTI" + assert len(r.candidates) >= 2 + + +def test_none(): + eng = _engine() + r = eng.match_one(3, "不存在的药", "X", "1", "无") + assert r.match_status == "NONE" + + +def test_alias_priority(): + eng = _engine() + key = build_match_key("阿卡波糖", "", "30片", "") # 平台奇葩写法 + eng.load_aliases({key: ("G001", "C001", "盒")}) + r = eng.match_one(4, "阿卡波糖", "", "30片", "") + assert r.match_status == "ALIAS" + assert r.goods_id == "G001" + + +def test_manufacturer_tolerance(): + # 进口英文厂家:大小写/标点容错 + k1 = build_match_key("药A", "H1", "1", "Abbott Biologicals B.V.") + k2 = build_match_key("药A", "H1", "1", "abbott biologicals bv") + assert k1 == k2 + + +if __name__ == "__main__": + import traceback + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = 0 + for fn in fns: + try: + fn() + print(f"PASS {fn.__name__}") + passed += 1 + except Exception: + print(f"FAIL {fn.__name__}") + traceback.print_exc() + print(f"\n{passed}/{len(fns)} passed") diff --git a/state_center/tests/test_web_api.py b/state_center/tests/test_web_api.py new file mode 100644 index 0000000..04e9c8a --- /dev/null +++ b/state_center/tests/test_web_api.py @@ -0,0 +1,796 @@ +import importlib +import sys +import types + +from fastapi.testclient import TestClient + + +def _install_fake_pymysql(): + pymysql = types.ModuleType("pymysql") + cursors = types.ModuleType("pymysql.cursors") + + class DictCursor: # noqa: D401 - test double + pass + + def connect(*args, **kwargs): # noqa: D401 - test double + raise AssertionError("connect should be monkeypatched in tests") + + cursors.DictCursor = DictCursor + pymysql.cursors = cursors + pymysql.connect = connect + sys.modules["pymysql"] = pymysql + sys.modules["pymysql.cursors"] = cursors + + +def test_timeline_api_returns_backend_computed_nodes(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __init__(self): + self._result = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM job WHERE job_id" in sql: + self._result = [{ + "job_id": "JOB-1", + "source_type": "YYC", + "current_node_code": "SOURCE_ADD", + "status": "WAITING_USER", + "needs_source_add": 1, + }] + elif "FROM job_node" in sql: + self._result = [ + {"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"}, + {"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"}, + ] + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + def fetchall(self): + return list(self._result) + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + resp = client.get("/api/jobs/JOB-1/timeline") + assert resp.status_code == 200 + body = resp.json() + by_code = {item["node_code"]: item for item in body["timeline"]} + assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS" + assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED" + assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER" + + +def test_state_machine_api_returns_chinese_labels(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + resp = client.get("/api/state-machine") + assert resp.status_code == 200 + body = resp.json() + assert body["node_labels"]["ORDER_FETCH"] == "订单获取" + assert body["executor_labels"]["RPA"] == "艺赛旗执行" + assert body["node_executor"]["PRODUCT_MATCH"] == "STATE_CENTER" + + +def test_health_reports_database_status(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + assert sql == "SELECT 1" + + def fetchone(self): + return {"ok": 1} + + class FakeConn: + def cursor(self): + return FakeCursor() + + def close(self): + pass + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"ok": True, "database": "ok"} + + +def test_task_submit_confirms_source_add_and_is_idempotent(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + state = { + "task": { + "task_id": "TASK-1", + "job_id": "JOB-1", + "node_code": "SOURCE_ADD", + "role_code": "PURCHASER", + "status": "OPEN", + "submit_token": None, + "submitted_action": None, + "submitted_payload_json": None, + "submitted_result_json": None, + }, + "job": { + "job_id": "JOB-1", + "current_node_code": "SOURCE_ADD", + "status": "WAITING_USER", + "needs_source_add": 1, + "current_role": "PURCHASER", + "source_type": "YYC", + }, + "job_nodes": [ + {"node_code": "SOURCE_ADD", "attempt_no": 1, "status": "WAITING_USER"}, + ], + } + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + self.last_sql = sql + self.last_params = params + if "FROM user_task WHERE task_id=%s" in sql: + self._result = [state["task"]] + elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: + self._result = [state["job"]] + elif "submitted_result_json" in sql and "FROM user_task" in sql: + self._result = [state["task"]] + elif "SELECT node_code,attempt_no,status" in sql: + self._result = list(state["job_nodes"]) + elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): + state["task"]["status"] = "SUBMITTED" + state["task"]["submit_token"] = params[0] + state["task"]["submitted_action"] = params[1] + state["task"]["submitted_payload_json"] = params[2] + state["task"]["submitted_result_json"] = params[3] + state["task"]["submitted_by"] = params[4] + state["task"]["submitted_at"] = params[5] + elif sql.startswith("UPDATE job SET needs_source_add=0"): + state["job"]["needs_source_add"] = 0 + elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): + state["job"]["current_node_code"] = params[0] + state["job"]["status"] = "PENDING" + elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): + state["job_nodes"][0]["status"] = "SUCCESS" + elif sql.startswith("INSERT INTO job_event"): + pass + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + def fetchall(self): + return list(self._result) + + @property + def _result(self): + return getattr(self, "_result_data", []) + + @_result.setter + def _result(self, value): + self._result_data = value + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} + first = client.post( + "/api/tasks/TASK-1/submit", + headers=headers, + json={ + "action": "CONFIRM_AND_CONTINUE", + "payload": {"sourceAdd": True}, + "submitToken": "token-1", + "operator": "alice", + }, + ) + assert first.status_code == 200 + first_body = first.json() + assert first_body["next_node"] == "CONTRACT_CREATE" + assert state["task"]["status"] == "SUBMITTED" + assert state["task"]["submitted_result_json"] is not None + assert state["job"]["current_node_code"] == "CONTRACT_CREATE" + assert state["job"]["needs_source_add"] == 0 + + second = client.post( + "/api/tasks/TASK-1/submit", + headers=headers, + json={ + "action": "CONFIRM_AND_CONTINUE", + "payload": {"sourceAdd": True}, + "submitToken": "token-1", + "operator": "alice", + }, + ) + assert second.status_code == 200 + assert second.json()["idempotent"] is True + + third = client.post( + "/api/tasks/TASK-1/submit", + headers=headers, + json={ + "action": "CONFIRM_AND_CONTINUE", + "payload": {"sourceAdd": True}, + "submitToken": "token-2", + "operator": "alice", + }, + ) + assert third.status_code == 409 + + +def test_task_submit_source_add_creates_missing_node_history(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + state = { + "task": { + "task_id": "TASK-1", + "job_id": "JOB-1", + "node_code": "SOURCE_ADD", + "role_code": "PURCHASER", + "status": "OPEN", + "submit_token": None, + "submitted_result_json": None, + }, + "job": { + "job_id": "JOB-1", + "current_node_code": "SOURCE_ADD", + "status": "WAITING_USER", + "needs_source_add": 1, + "current_role": "PURCHASER", + "source_type": "YYC", + }, + "job_nodes": [], + } + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM user_task WHERE task_id=%s" in sql: + self._result = [state["task"]] + elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql: + self._result = [state["job"]] + elif sql.startswith("UPDATE job SET needs_source_add=0"): + state["job"]["needs_source_add"] = 0 + elif sql.startswith("INSERT INTO job_node"): + state["job_nodes"].append({ + "node_code": params[1], + "attempt_no": params[3], + "status": "SUCCESS", + "executor_type": params[2], + }) + elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"): + state["job"]["current_node_code"] = params[0] + state["job"]["status"] = "PENDING" + elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"): + state["task"]["status"] = "SUBMITTED" + state["task"]["submit_token"] = params[0] + state["task"]["submitted_result_json"] = params[3] + elif sql.startswith("INSERT INTO job_event"): + pass + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + @property + def _result(self): + return getattr(self, "_result_data", []) + + @_result.setter + def _result(self, value): + self._result_data = value + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + response = client.post( + "/api/tasks/TASK-1/submit", + headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, + json={ + "action": "CONFIRM_AND_CONTINUE", + "payload": {}, + "submitToken": "source-submit-1", + "operator": "采购张三", + }, + ) + + assert response.status_code == 200 + assert state["job_nodes"] == [{ + "node_code": "SOURCE_ADD", + "attempt_no": 1, + "status": "SUCCESS", + "executor_type": "HUMAN", + }] + + +def test_advance_to_m5_human_node_creates_task(): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + + state = { + "job": { + "job_id": "JOB-1", + "supplier_name": "重庆亿昊药业", + "current_node_code": "M5_CREATE_ORDER", + "status": "RUNNING", + }, + "job_nodes": [], + "tasks": [], + } + + class FakeCursor: + def execute(self, sql, params=None): + if sql.startswith("INSERT INTO job_node"): + state["job_nodes"].append({ + "node_code": params[1], + "executor_type": params[2], + "attempt_no": params[3], + "status": params[4], + }) + elif sql.startswith("DELETE FROM user_task"): + pass + elif sql.startswith("UPDATE job SET current_node_code=%s,status='WAITING_USER'"): + state["job"]["current_node_code"] = params[0] + state["job"]["status"] = "WAITING_USER" + state["job"]["current_role"] = params[1] + elif sql.startswith("INSERT INTO user_task"): + state["tasks"].append({ + "job_id": params[1], + "node_code": params[2], + "role_code": params[3], + "title": params[4], + }) + elif sql.startswith("INSERT INTO job_event"): + pass + else: + raise AssertionError(sql) + + nxt = main._advance(FakeCursor(), state["job"], "M5_CREATE_ORDER") + + assert nxt == "M5_MANUAL_CONFIRM" + assert state["job"]["status"] == "WAITING_USER" + assert state["job"]["current_role"] == "PURCHASER" + assert state["job_nodes"] == [{ + "node_code": "M5_MANUAL_CONFIRM", + "executor_type": "HUMAN", + "attempt_no": 1, + "status": "WAITING_USER", + }] + assert state["tasks"] == [{ + "job_id": "JOB-1", + "node_code": "M5_MANUAL_CONFIRM", + "role_code": "PURCHASER", + "title": "M5人工确认:重庆亿昊药业", + }] + + +def test_node_complete_exports_artifacts(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + state = { + "job": { + "job_id": "JOB-1", + "current_node_code": "CONTRACT_CREATE", + "status": "RUNNING", + "source_type": "YYC", + }, + "job_node": { + "node_code": "CONTRACT_CREATE", + "attempt_no": 1, + "status": "RUNNING", + }, + "artifacts": [], + } + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM job WHERE job_id=%s" in sql and "ORDER BY" not in sql: + self._result = [state["job"]] + elif "FROM job_node WHERE job_id=%s" in sql: + self._result = [state["job_node"]] + elif "FROM user_task WHERE job_id=%s" in sql: + self._result = [] + elif "FROM job_artifact WHERE job_id=%s" in sql: + self._result = list(state["artifacts"]) + elif sql.startswith("UPDATE job_node SET status='SUCCESS'"): + state["job_node"]["status"] = "SUCCESS" + state["job_node"]["ended_at"] = params[1] + if len(params) > 2: + state["job_node"]["callback_idempotency_key"] = params[2] + elif sql.startswith("INSERT INTO job_artifact"): + state["artifacts"].append({ + "job_id": params[0], + "node_code": params[1], + "attempt_no": params[2], + "artifact_type": params[3], + "storage_path": params[4], + "file_hash": params[5], + "visible_roles": params[6], + "is_masked": params[7], + }) + elif sql.startswith("INSERT INTO job_event"): + pass + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + def fetchall(self): + if self._result: + return list(self._result) + return [] + + @property + def _result(self): + return getattr(self, "_result_data", []) + + @_result.setter + def _result(self, value): + self._result_data = value + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + headers = {"X-Agent-Token": main.settings.AGENT_TOKEN} + done = client.post( + "/api/jobs/JOB-1/nodes/CONTRACT_CREATE/complete", + headers=headers, + json={ + "attemptNo": 1, + "executorRunId": "RUN-1", + "idempotencyKey": "idem-1", + "businessRefs": {"contractSystemNo": "HT-001"}, + "summary": {"done": True}, + "artifacts": [ + { + "artifactType": "CONTRACT_PDF", + "storagePath": "/data/jobs/JOB-1/contract.pdf", + "fileHash": "sha256:abc", + "visibleRoles": ["PURCHASER", "PLANNER"], + "isMasked": False, + } + ], + }, + ) + + assert done.status_code == 200 + + package = client.get("/api/jobs/JOB-1/evidence-package") + assert package.status_code == 200 + body = package.json() + assert body["artifacts"][0]["artifact_type"] == "CONTRACT_PDF" + assert body["artifacts"][0]["storage_path"] == "/data/jobs/JOB-1/contract.pdf" + + +def test_job_list_api_returns_compact_rows(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM job" in sql: + self._result = [{ + "job_id": "JOB-1", + "supplier_name": "重庆亿昊药业", + "order_date": "2026-08-04", + "source_type": "YYC", + "status": "RUNNING", + "current_node_code": "PRODUCT_MATCH", + "current_role": "PLANNER", + "created_at": "2026-08-10 10:00:00", + "updated_at": "2026-08-10 10:05:00", + "finished_at": None, + }] + else: + self._result = [] + + def fetchall(self): + return list(self._result) + + @property + def _result(self): + return getattr(self, "_result_data", []) + + @_result.setter + def _result(self, value): + self._result_data = value + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + resp = client.get("/api/jobs", params={"status": "RUNNING"}) + assert resp.status_code == 200 + body = resp.json() + assert body["items"][0]["job_id"] == "JOB-1" + assert body["items"][0]["supplier_name"] == "重庆亿昊药业" + assert body["items"][0]["current_node_code"] == "PRODUCT_MATCH" + + +def test_task_list_api_filters_by_role(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM user_task" in sql: + self._result = [{ + "task_id": "TASK-1", + "job_id": "JOB-1", + "node_code": "SOURCE_ADD", + "role_code": "PURCHASER", + "status": "OPEN", + "title": "新增货源:重庆亿昊药业", + "reason_type": "SOURCE_ADD", + "submitted_action": None, + "submitted_at": None, + "created_at": "2026-08-10 10:00:00", + }] + else: + self._result = [] + + def fetchall(self): + return list(self._result) + + @property + def _result(self): + return getattr(self, "_result_data", []) + + @_result.setter + def _result(self, value): + self._result_data = value + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + + resp = client.get("/api/tasks/my", params={"roleCode": "PURCHASER", "status": "open"}) + assert resp.status_code == 200 + body = resp.json() + assert body["items"][0]["task_id"] == "TASK-1" + assert body["items"][0]["role_code"] == "PURCHASER" + + +def test_ingest_order_items_before_match(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + executed = [] + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + executed.append((sql, params)) + if "SELECT * FROM job WHERE job_id=%s" in sql: + self._result = [{ + "job_id": "JOB-1", + "current_node_code": "ORDER_FETCH", + "status": "RUNNING", + }] + elif "FROM product_match" in sql: + self._result = [{"c": 0}] + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + response = client.post( + "/api/jobs/JOB-1/items", + headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, + json={ + "replace": True, + "rows": [{ + "orderNo": "DD-001", + "rawProductName": "阿卡波糖片", + "platformProductCode": "PT-001", + "spec": "50mg*30片", + "manufacturer": "示例制药", + "approvalNo": "国药准字H00000001", + "qty": 12, + "unitPrice": 9.8, + "sourceRowNo": 2, + }], + }, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True, "inserted": 1, "order_nos": ["DD-001"]} + assert any(sql.startswith("INSERT INTO order_item") for sql, _ in executed) + assert any("order_no_list_json" in sql for sql, _ in executed) + + +def test_ingest_order_items_rejects_replace_after_match(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "SELECT * FROM job WHERE job_id=%s" in sql: + self._result = [{"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH"}] + elif "FROM product_match" in sql: + self._result = [{"c": 1}] + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + response = client.post( + "/api/jobs/JOB-1/items", + headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, + json={"replace": True, "rows": []}, + ) + + assert response.status_code == 409 + assert "对码结果" in response.json()["detail"] + + +def test_load_fixture_goods_for_local_demo(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + monkeypatch.setattr(main.settings, "DEV_MODE", True, raising=False) + response = client.post( + "/api/dev/match/load-fixture-goods", + headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, + ) + + assert response.status_code == 200 + assert response.json()["loaded"] >= 3 + assert main._ENGINE_LOADED is True + + +def test_select_candidate_requires_candidate_payload(monkeypatch): + _install_fake_pymysql() + main = importlib.import_module("state_center.app.main") + client = TestClient(main.app) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + if "FROM user_task WHERE task_id=%s" in sql: + self._result = [{ + "task_id": "TASK-1", + "job_id": "JOB-1", + "node_code": "PRODUCT_MATCH", + "role_code": "PLANNER", + "status": "OPEN", + "submit_token": None, + "product_match_id": 7, + }] + elif "FROM job WHERE job_id=%s" in sql: + self._result = [{ + "job_id": "JOB-1", + "current_node_code": "PRODUCT_MATCH", + "status": "WAITING_USER", + }] + else: + self._result = [] + + def fetchone(self): + return self._result[0] if self._result else None + + class FakeConn: + def cursor(self): + return FakeCursor() + + monkeypatch.setattr(main, "get_conn", lambda: FakeConn()) + response = client.post( + "/api/tasks/TASK-1/submit", + headers={"X-Agent-Token": main.settings.AGENT_TOKEN}, + json={ + "action": "SELECT_CANDIDATE", + "payload": {}, + "submitToken": "submit-1", + "operator": "计划员张三", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "选择候选货品时必须提供 productMatchId 和 goodsId" diff --git a/state_center/tests/test_web_state_machine.py b/state_center/tests/test_web_state_machine.py new file mode 100644 index 0000000..bb5acf2 --- /dev/null +++ b/state_center/tests/test_web_state_machine.py @@ -0,0 +1,23 @@ +from state_center.app.web_state_machine import build_timeline + + +def test_build_timeline_marks_skipped_and_waiting_user(): + job = { + "job_id": "JOB-1", + "source_type": "YYC", + "current_node_code": "SOURCE_ADD", + "status": "WAITING_USER", + "needs_source_add": 1, + } + job_nodes = [ + {"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"}, + {"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"}, + ] + + timeline = build_timeline(job, job_nodes) + by_code = {item["node_code"]: item for item in timeline} + + assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS" + assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED" + assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER" + assert by_code["CONTRACT_CREATE"]["display_status"] == "PENDING" diff --git a/state_center/tests/test_yisaiqi_contract_create.py b/state_center/tests/test_yisaiqi_contract_create.py new file mode 100644 index 0000000..56a66c8 --- /dev/null +++ b/state_center/tests/test_yisaiqi_contract_create.py @@ -0,0 +1,112 @@ +import json + +from rpa_yisaiqi.callback_client import StateCenterClient +from rpa_yisaiqi.contract_create_node import ( + ContractCreateInput, + LoginFailed, + execute_contract_create, +) + + +class FakeHttp: + def __init__(self, responses=None): + self.requests = [] + self.responses = list(responses or [{"ok": True}, {"ok": True, "next_node": "CONTRACT_APPROVE_WAIT"}]) + + def __call__(self, request, timeout): + body = json.loads(request.data.decode("utf-8")) if request.data else None + self.requests.append( + { + "url": request.full_url, + "headers": dict(request.header_items()), + "body": body, + "timeout": timeout, + } + ) + return FakeResponse(self.responses.pop(0)) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_state_center_client_sends_agent_token_and_json_body(): + http = FakeHttp([{"ok": True}]) + client = StateCenterClient("http://state-center:8000/", "dev-agent-token", opener=http) + + result = client.start_node("JOB-1", "CONTRACT_CREATE", attempt_no=2, operator="agent") + + assert result == {"ok": True} + assert http.requests[0]["url"] == "http://state-center:8000/api/jobs/JOB-1/nodes/CONTRACT_CREATE/start" + assert http.requests[0]["headers"]["X-agent-token"] == "dev-agent-token" + assert http.requests[0]["body"] == { + "attemptNo": 2, + "executorPlatform": "iS-RPA", + "operator": "agent", + } + + +def test_state_center_client_escapes_non_ascii_path_parts(): + http = FakeHttp([{"ok": True}]) + client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) + + client.start_node("YYC-合同节点联调-001", "CONTRACT_CREATE") + + assert http.requests[0]["url"] == ( + "http://state-center:8000/api/jobs/" + "YYC-%E5%90%88%E5%90%8C%E8%8A%82%E7%82%B9%E8%81%94%E8%B0%83-001" + "/nodes/CONTRACT_CREATE/start" + ) + + +def test_contract_create_calls_start_then_complete_with_contract_number(): + http = FakeHttp() + client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) + spec = ContractCreateInput( + job_id="JOB-1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="YYC", + contract_system_no="HT-20260811-001", + ) + + result = execute_contract_create(spec, client) + + assert result["next_node"] == "CONTRACT_APPROVE_WAIT" + assert [request["url"].split("/")[-1] for request in http.requests] == ["start", "complete"] + assert http.requests[1]["body"]["businessRefs"] == {"contractSystemNo": "HT-20260811-001"} + assert http.requests[1]["body"]["summary"]["supplierName"] == "重庆亿昊药业" + assert http.requests[1]["body"]["summary"]["contractDate"] == "2026-08-11" + + +def test_contract_create_reports_login_failure_to_state_center(): + http = FakeHttp([{"ok": True}, {"ok": True, "job_status": "FAILED_RETRYABLE"}]) + client = StateCenterClient("http://state-center:8000", "dev-agent-token", opener=http) + spec = ContractCreateInput( + job_id="JOB-1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="YYC", + contract_system_no="HT-20260811-001", + ) + + def failing_runner(_spec): + raise LoginFailed("合同系统登录失败") + + result = execute_contract_create(spec, client, contract_runner=failing_runner) + + assert result["job_status"] == "FAILED_RETRYABLE" + assert http.requests[1]["url"].endswith("/api/jobs/JOB-1/nodes/CONTRACT_CREATE/fail") + assert http.requests[1]["body"]["errorCode"] == "SYS_LOGIN_FAIL" + assert http.requests[1]["body"]["retryable"] is True + assert http.requests[1]["body"]["routeRole"] == "OPS" diff --git a/state_center/tests/test_yisaiqi_contract_draft.py b/state_center/tests/test_yisaiqi_contract_draft.py new file mode 100644 index 0000000..41ec89c --- /dev/null +++ b/state_center/tests/test_yisaiqi_contract_draft.py @@ -0,0 +1,138 @@ +import json +from pathlib import Path + +import pytest + +from rpa_yisaiqi.contract_create_node import ( + ContractCreateError, + ContractCreateInput, + PdfMergeUnsupported, + create_contract_in_contract_system, +) +from rpa_yisaiqi.contract_draft import load_contract_draft +from rpa_yisaiqi.pdf_utils import ensure_merged_pdf + + +def test_load_contract_draft_maps_full_contract_inputs(tmp_path): + draft_path = tmp_path / "contract-draft.json" + draft_path.write_text( + json.dumps( + { + "supplierName": "重庆亿昊药业", + "orderDate": "2026-08-11", + "sourceType": "YYC", + "templateType": "NON_FORMAT", + "templateName": "非格式--采购协议", + "customerCode": "CUST-001", + "customerName": "重庆亿昊药业", + "contractCategory": "采购协议", + "settlementType": "付款", + "contractBackground": "按订单自动起草采购协议", + "contractNature": "主合同", + "amountCurrency": "人民币", + "amount": "12000.50", + "contractEndDate": "2026-12-31", + "isMajorContract": "否", + "isPrepayment": "否", + "belongYear": "2026", + "paymentMethod": "承兑", + "paymentDays": "45", + "contractStatus": "正式合同", + "containsBloodProducts": "否", + "platformPdfPaths": ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"], + "goodsLines": [ + { + "operationCode": "ADD", + "goodsCode": "YP-001", + "goodsName": "阿卡波糖片", + "approvalNo": "国药准字H00000001", + "spec": "50mg*30片", + "unit": "盒", + "quantity": 12, + } + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + draft = load_contract_draft(draft_path) + + assert draft.supplier_name == "重庆亿昊药业" + assert draft.template_type == "NON_FORMAT" + assert draft.template_name == "非格式--采购协议" + assert draft.customer_code == "CUST-001" + assert draft.customer_name == "重庆亿昊药业" + assert draft.contract_category == "采购协议" + assert draft.settlement_type == "付款" + assert draft.contract_background == "按订单自动起草采购协议" + assert draft.amount == "12000.50" + assert draft.contract_end_date == "2026-12-31" + assert draft.belong_year == "2026" + assert draft.payment_method == "承兑" + assert draft.contract_status == "正式合同" + assert draft.contains_blood_products == "否" + assert draft.platform_pdf_paths == ["C:/jobs/JOB-1/a.pdf", "C:/jobs/JOB-1/b.pdf"] + assert draft.goods_lines[0].goods_code == "YP-001" + + +def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path): + draft_path = tmp_path / "draft.json" + draft_path.write_text( + json.dumps( + { + "supplierName": "重庆亿昊药业", + "orderDate": "2026-08-11", + "sourceType": "YYC", + "goodsLines": [], + "platformPdfPaths": [], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + spec = ContractCreateInput( + job_id="JOB-1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="YYC", + draft_path=str(draft_path), + ) + + with pytest.raises(PdfMergeUnsupported): + create_contract_in_contract_system(spec) + + +def test_ensure_merged_pdf_uses_single_pdf_without_merge(tmp_path): + source = tmp_path / "one.pdf" + source.write_bytes(b"%PDF-1.4\n%one\n") + + assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source) + + +def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path): + draft_path = tmp_path / "draft.json" + draft_path.write_text( + json.dumps( + { + "supplierName": "小药药供应商", + "orderDate": "2026-08-11", + "sourceType": "XYY", + "goodsLines": [], + "platformPdfPaths": [], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + spec = ContractCreateInput( + job_id="JOB-1", + supplier_name="小药药供应商", + order_date="2026-08-11", + source_type="XYY", + draft_path=str(draft_path), + ) + + with pytest.raises(ContractCreateError, match="YiSaiQiBrowserAdapter"): + create_contract_in_contract_system(spec) diff --git a/state_center/tests/test_yisaiqi_contract_flow_steps.py b/state_center/tests/test_yisaiqi_contract_flow_steps.py new file mode 100644 index 0000000..bbc29c8 --- /dev/null +++ b/state_center/tests/test_yisaiqi_contract_flow_steps.py @@ -0,0 +1,215 @@ +from rpa_yisaiqi.contract_flow_steps import ( + ContractFlowContext, + ContractGoodsLine, + run_contract_create_flow, +) + + +class FakeBrowser: + def __init__(self): + self.calls = [] + self.read_values = { + "contract_number": "HT-20260811-REAL-001", + } + + def open_url(self, url): + self.calls.append(("open_url", url)) + + def fill_first(self, selectors, value): + self.calls.append(("fill_first", tuple(selectors), value)) + + def click_first(self, selectors): + self.calls.append(("click_first", tuple(selectors))) + + def wait_for_text(self, text, timeout_seconds=15): + self.calls.append(("wait_for_text", text, timeout_seconds)) + + def select_first(self, selectors, value): + self.calls.append(("select_first", tuple(selectors), value)) + + def upload_file_first(self, selectors, file_path): + self.calls.append(("upload_file_first", tuple(selectors), file_path)) + + def read_first(self, selectors, value_name): + self.calls.append(("read_first", tuple(selectors), value_name)) + return self.read_values[value_name] + + def screenshot(self, name): + self.calls.append(("screenshot", name)) + return f"/tmp/{name}.png" + + +def test_contract_flow_logs_in_with_real_contract_login_selectors(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="合同节点联调供应商", + order_date="2026-08-11", + source_type="XYY", + template_type="FORMAT", + goods_lines=[], + ) + + run_contract_create_flow(browser, ctx) + + assert browser.calls[:5] == [ + ("open_url", "http://contract-test.gzmpc.com:8080/"), + ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002"), + ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1"), + ("click_first", ('input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录')), + ("wait_for_text", "合同", 20), + ] + + +def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="合同节点联调供应商", + order_date="2026-08-11", + source_type="YYC", + template_type="FORMAT", + goods_lines=[ + ContractGoodsLine( + operation_code="ADD", + goods_code="YP-001", + goods_name="阿卡波糖片", + approval_no="国药准字H00000001", + spec="50mg*30片", + unit="盒", + quantity=12, + ) + ], + merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", + ) + + contract_no = run_contract_create_flow(browser, ctx) + + assert contract_no == "HT-20260811-REAL-001" + assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls + assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls + assert ("upload_file_first", ('input[type="file"]', 'label=合同证文上传'), "C:/rpa/jobs/JOB-1/contract-merged.pdf") in browser.calls + + +def test_contract_flow_skips_pdf_upload_for_xyy(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="小药药供应商", + order_date="2026-08-11", + source_type="XYY", + template_type="FORMAT", + goods_lines=[], + merged_pdf_path="C:/should-not-upload.pdf", + ) + + run_contract_create_flow(browser, ctx) + + assert not [call for call in browser.calls if call[0] == "upload_file_first"] + + +def test_contract_flow_enters_real_draft_menu_and_recent_template(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="YYC", + template_type="NON_FORMAT", + template_name="非格式--采购协议", + goods_lines=[], + merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", + ) + + run_contract_create_flow(browser, ctx) + + assert ("click_first", ("text=合同管理", "aria=合同管理")) in browser.calls + assert ("click_first", ("text=合同起草", "aria=合同起草")) in browser.calls + assert ("wait_for_text", "最近使用模板", 20) in browser.calls + assert ( + "click_first", + ("text=非格式--采购协议", "text=非格式--采购协议...", "title=非格式--采购协议"), + ) in browser.calls + + +def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="XYY", + template_type="NON_FORMAT", + template_name="非格式--采购协议", + customer_code="CUST-001", + customer_name="重庆亿昊药业", + contract_category="采购协议", + settlement_type="付款", + contract_background="按订单自动起草采购协议", + contract_nature="主合同", + amount_currency="人民币", + amount="12000.50", + contract_end_date="2026-12-31", + is_major_contract="否", + is_prepayment="否", + belong_year="2026", + payment_method="账期支付", + payment_days="30", + contract_status="正式合同", + contains_blood_products="否", + goods_lines=[], + ) + + run_contract_create_flow(browser, ctx) + + assert ("fill_first", ('label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'), "CUST-001") in browser.calls + assert ("fill_first", ('label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'), "重庆亿昊药业") in browser.calls + assert ("select_first", ('label=合同类别', 'select[name="fdContractCategory"]'), "采购协议") in browser.calls + assert ("select_first", ('label=结算类型', 'select[name="fdSettlementType"]'), "付款") in browser.calls + assert ("fill_first", ('label=合同背景概述', 'textarea[name="fdContractBackground"]'), "按订单自动起草采购协议") in browser.calls + assert ("click_first", ("label=主合同", "text=主合同")) in browser.calls + assert ("select_first", ('label=合同金额', 'select[name="fdCurrency"]'), "人民币") in browser.calls + assert ("fill_first", ('label=金额(元)', 'input[name="fdAmount"]'), "12000.50") in browser.calls + assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls + assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls + assert ("fill_first", ('label=合同结束日期', 'input[name="fdEndDate"]', 'input[placeholder*="合同结束"]'), "2026-12-31") in browser.calls + assert ("select_first", ('label=是否重大合同', 'select[name="fdIsMajorContract"]'), "否") in browser.calls + assert ("select_first", ('label=是否预付款', 'select[name="fdIsPrepayment"]'), "否") in browser.calls + assert ("fill_first", ('label=合同归属年份', 'input[name="fdBelongYear"]'), "2026") in browser.calls + assert ("select_first", ('label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'), "账期支付") in browser.calls + assert ("fill_first", ('label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'), "30") in browser.calls + assert ("select_first", ('label=合同状态', 'select[name="fdContractStatus"]'), "正式合同") in browser.calls + assert ("select_first", ('label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'), "否") in browser.calls + + +def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state(): + browser = FakeBrowser() + ctx = ContractFlowContext( + contract_url="http://contract-test.gzmpc.com:8080/", + username="test002", + password="1", + supplier_name="重庆亿昊药业", + order_date="2026-08-11", + source_type="XYY", + template_type="NON_FORMAT", + template_name="非格式--采购协议", + submit_mode="dry_run", + goods_lines=[], + ) + + contract_no = run_contract_create_flow(browser, ctx) + + assert not [call for call in browser.calls if call[0] == "click_first" and "text=提交" in call[1]] + assert ("screenshot", "contract-dry-run-ready") in browser.calls + assert contract_no == "" + assert not [call for call in browser.calls if call[0] == "read_first"] diff --git a/state_center/给Trae的开发交接.md b/state_center/给Trae的开发交接.md new file mode 100644 index 0000000..3b0f950 --- /dev/null +++ b/state_center/给Trae的开发交接.md @@ -0,0 +1,110 @@ +# 给 Trae 的开发交接说明 + +> 你(Trae)接手"采购主链RPA一期 · 状态中心"的后续开发。本文是你的入口,先读这份,再读下面点名的文件。写代码前把"硬规则"和"你不做什么"看完,别越界。 + +## 一、你要干什么(一句话) + +做**状态中心**:一个 Python(FastAPI)+ MySQL 的后台 + 配套网页。它负责"每一单跑到哪步、出错派给谁、存状态和证据、画步骤树状图给业务看"。它**自己不去点任何外部系统页面**——点页面是艺赛旗 RPA 的活,通过 HTTP 回调跟你交互。 + +## 二、先读这几个文件(按顺序) + +1. `资料/采购主链RPA一期需求文档_三方会审版.md` —— 业务规则(节点N01~N10、字段、异常)。 +2. `资料/采购主链RPA一期设计文档.md` —— 技术设计(表结构、状态机、接口契约、对码引擎)。 +3. `资料/采购主链RPA一期实施与验收规范.md` —— **每个节点的操作步骤 + 验收标准(代码+业务两栏)**,你做完对着这里勾。 +4. `state_center/README.md` —— 已有代码怎么起、怎么验。 + +## 三、技术栈(已定,别改) + +- 后台:Python 3 + FastAPI +- 库:MySQL 8(状态中心自己的库,建库脚本 `sql/schema.sql` 已给) +- 对码货品主数据:在 **Oracle**,只读直连查,**不要复制成本地表** +- OCR:本地 Python 服务(PaddleOCR),仅线下手工单用 +- 前端:Vue 或 React(待办页 + 步骤树状图 + 整单查询) +- RPA 下层:艺赛旗 iS-RPA(不是你做,你只跟它的 HTTP 回调对接) + +## 四、已经写好的(别重写,在这上面接着做) + +`state_center/` 里已有可跑的第1步骨架: + +| 文件 | 已实现 | 状态 | +| --- | --- | --- | +| `sql/schema.sql` | 8张表建库脚本 | 完成 | +| `app/config.py` | MySQL/Oracle 连接、Oracle表名列名占位 | 完成 | +| `app/db.py` | MySQL 访问(pymysql) | 完成 | +| `app/match_engine.py` | 对码引擎:两级命中+规格标准化+Oracle取数函数 | 完成,单测6/6过 | +| `app/main.py` | 建单 + start/complete/fail 三回调(含幂等/乱序拒绝)+ 合同审批等待 + 整单查询 | 骨架完成 | +| `tests/test_match_engine.py` | 对码单测 | 通过 | + +> 状态机流转已通(**13 节点 = 11 主 + 2 条件** 显式转移表、业务编号回写、回调幂等、乱序拒绝 409、合同审批等待态、M5质管缺失→FAILED_MANUAL、M5不自动保存),用假回调能把一个 job 从头走到尾。 +> +> 节点数口径:主链 11 步每单都走;`OCR_EXTRACT`(仅线下单)、`SOURCE_ADD`(仅缺货源)是条件节点,不满足自动跳过。代码 `NODE_ORDER` 列全 13 个,`TRANSITIONS` 定转移,`NODE_EXECUTOR` 标执行方(STATE_CENTER/RPA/HUMAN)。 + +## 五、你的任务清单(按顺序做,每步对着验收规范勾) + +### 任务1:把骨架在真 MySQL 上跑通 +- 建库、起服务,按 README 的 curl 把一个 job 用假回调走完主链(线上单会自动跳过 OCR_EXTRACT/SOURCE_ADD 条件节点)。 +- 验收:`GET /api/jobs/{id}` 能看到完整节点轨迹和状态。 + +### 任务2:对码接口 + Oracle 真连 +- 拿到 Oracle 连接串/表名列名后,填 `config.py`,`pip install oracledb`。 +- **骨架已写好**(`main.py`),你主要是接真数据、补前置: + - `POST /api/match/reload-goods`:开工从 Oracle 全量拉货品主数据装进内存引擎(依赖 ORACLE_* 配置)。 + - `POST /api/jobs/{id}/match/run`:对该 job 的 `order_item` 跑对码 → 落 `product_match`;MULTI/NONE 建 `user_task` 转 WAITING_USER,全命中推进下一节点。**前置:`order_item` 要先落库**(建单/ORDER_FETCH 后把明细写进来),否则 run 会 400。 + - `POST /api/match/{matchId}/confirm`:人工确认一条,回写 product_match + 沉淀 `mapping_config` 累积别名(下次自动 ALIAS 命中),全部解决后自动推进。 +- 已补齐的关键边界: + - 对码重跑幂等:`product_match(job_id,order_item_id)` 当前态唯一,`match/run` upsert 结果并重建 OPEN 待办。 + - 货源判定:`goods_id` 明确后,按历史 `supplier_name + goods_id` 判断是否已有货源;缺货源会写 `product_match.source_add_needed/job.needs_source_add` 并创建 `SOURCE_ADD` 聚合待办。 + - alias 沉淀:同一 `config_key` 先失效旧 active,再插新 active;装载时按固定顺序取当前结果。 +- 你要补的:① 订单明细落 `order_item` 的入口;② `GET /api/jobs/{id}/match/unmatched`(列未命中给前端);③ H5 待办提交入口(把 `SOURCE_ADD` 完成动作接到 `nodes/SOURCE_ADD/complete`)。 +- **只写 mapping_config 累积别名,绝不回写 Oracle 主数据**(主数据只读)。 +- 验收:喂真实订单,命中率有数字,未命中进待办,确认后能自动往下走。 + +### 任务3:待办 + 人工提交 +- `GET /api/tasks/my?roleCode=&status=open`、`POST /api/tasks/{id}/submit`。 +- 动作:SELECT_CANDIDATE(对码选候选)/CONFIRM_AND_CONTINUE/RETRY_CURRENT_NODE/TRANSFER_MANUAL/TERMINATE_JOB。 +- 幂等:task_id + submit_token 防重复提交。 + +### 任务4:前端网页 +- 步骤树状图(样式见实施规范第三部分)、待办页、整单查询页。 +- 验收:业务打开能看到每单跑到哪、卡在哪、去处理按钮跳待办。 + +### 任务5:证据 + 事件流 + 企业微信 +- 证据落盘(job_artifact),事件流已有基础(job_event)。 +- 企业微信推待办、H5 身份换 JWT。 + +### 任务6:调度与重试 +- 定时触发建单(跑前一天订单)、系统类错误自动重试1-2次、CONTRACT_APPROVE_WAIT 轮询(别死等)、超时升级。 + +## 六、硬规则(红线,违反就是错的) + +1. **对码表在 Oracle,只读,绝不复制成本地表、绝不回写 Oracle**。累积别名写自己的 mapping_config。 +2. **M5 质管资料缺失(errorCode=M5_QC_MISSING)→ 不重试、不等质管,直接 FAILED_MANUAL 转人工**。已在 main.py 实现,别改成重试。 +3. **M5 一期不自动保存/签订**(auto_save_enabled 默认0),做成开关,阶段二才放开。别默认自动。 +4. **关键送审在返利中心(REBATE_SUBMIT),不是合同系统**。不送审 M5 带不出协议,是硬阻断。 +5. **签约日期 = 订单日期**(不是今天),合同建单时按订单日期填。 +6. **医药城同供应商同日合同合并成一个PDF**再上传(传第二份会覆盖),小药药不上传。 +7. **返利细单备注必填订单号、特定补偿必填0**。 +8. **对码未命中/返利重复编码 = 单条挂起,整批继续**;不是停整批。 +9. **所有写接口必须鉴权**;RPA 回调校验 X-Agent-Token + 幂等键,禁止无认证暴露。 +10. 节点失败不覆盖历史,attempt_no+1 新建 job_node 行。 + +## 七、你不做什么(边界) + +- **不写点外部系统页面的代码**(登录/导Excel/建合同/M5录入)——那是艺赛旗,你只收它的 HTTP 回调。 +- 不做招财平台(一期不纳入)。 +- 不做加单/拆单全自动重做(先记录父订单号,转人工)。 +- 不做返利重复编码全自动重建(规则未定,先转人工)。 +- 遇到文档里标 `【待确认】` 的(订单状态过滤、OCR部署方式等),别自己拍板,先留 TODO 问项目负责人。 + +## 八、回调契约(艺赛旗会这样调你,已实现) + +``` +POST /api/jobs/{jobId}/nodes/{nodeCode}/start header: X-Agent-Token +POST /api/jobs/{jobId}/nodes/{nodeCode}/complete 带 businessRefs/summary +POST /api/jobs/{jobId}/nodes/{nodeCode}/fail 带 errorCode/retryable/routeRole +``` +字段细节看 `设计文档` 第六部分和 `main.py` 里的 Pydantic 模型。 + +## 九、验收总纲 + +每做完一个节点/接口,打开 `实施与验收规范` 第四部分,对着该节点的**代码验收**(你自检)和**业务验收**(交业务勾)两栏逐条过。全绿才算完。 diff --git a/state_center/给艺赛旗的开发交接.md b/state_center/给艺赛旗的开发交接.md new file mode 100644 index 0000000..af3948a --- /dev/null +++ b/state_center/给艺赛旗的开发交接.md @@ -0,0 +1,197 @@ +# 给艺赛旗(iS-RPA)的开发交接说明 + +> 你(艺赛旗开发)负责"采购主链RPA一期"里**点外部系统页面**的活:登录、导订单、建合同、返利中心操作、M5 录单。每做完一步,用 HTTP 回调告诉"状态中心"结果。状态中心是大脑(记每单跑到哪、派活、存证据),你是手脚(照它给的顺序点页面)。你俩**只通过 HTTP JSON 回调对接,不共享数据库**。 + +## 一、你要干什么(一句话) + +按状态中心下发的 job(一个供应商+一个订单日期=一条合同),在各外部系统页面上完成对应节点的操作,每个节点用三个回调(start / complete / fail)把状态回传。 + +## 二、你和状态中心怎么对接(核心) + +``` +状态中心(Python/FastAPI) 艺赛旗 iS-RPA + 记状态/派活/存证据 <--HTTP--> 点页面/填表/上传 + | | + | 1) 你开始一个节点: POST .../start | + | 2) 点完页面成功: POST .../complete | + | 3) 出错: POST .../fail | +``` + +- **状态中心不会调用你**(一期没有反向下发);是**你主动调它**三个回调。 +- 每次调用**必须带 header `X-Agent-Token`**(默认 `dev-agent-token`,上线换正式值,找状态中心开发要)。 +- 节点顺序由状态中心维护,你 complete 后它自己算下一个节点是谁并返回 `next_node`,你**照着它返回的 next_node 跑下一步**,不要自己写死顺序。 + +## 三、三个回调契约(照抄字段,别改名) + +基础地址:`http://<状态中心地址>:8000`(联调时给你实际 IP)。所有回调 header 带 `X-Agent-Token: `、`Content-Type: application/json`。 + +### 3.1 节点开始 start + +``` +POST /api/jobs/{jobId}/nodes/{nodeCode}/start +{ + "attemptNo": 1, // 第几次尝试,重试就 +1 + "executorPlatform": "iS-RPA", // 可选,标记是哪个RPA平台跑的 + "operator": "agent" // 可选 +} +``` +返回 `{"ok": true}`。**开始点页面前先调一次**,让状态中心把该节点标 RUNNING。 + +### 3.2 节点成功 complete + +``` +POST /api/jobs/{jobId}/nodes/{nodeCode}/complete +{ + "attemptNo": 1, + "executorRunId": "YD-RUN-001", // 可选,你这边的流程运行号,便于回查 + "businessRefs": { // 业务编号回写(见下表,产出编号的节点必传) + "contractSystemNo": "HT2026..." + }, + "summary": { "orderCount": 12 } // 可选,给业务看的摘要 +} +``` +返回 `{"ok": true, "next_node": "OCR_EXTRACT"}`。**拿到 next_node 跑下一节点**;`next_node=null` 表示整单跑完。 + +### 3.3 节点失败 fail + +``` +POST /api/jobs/{jobId}/nodes/{nodeCode}/fail +{ + "attemptNo": 1, + "errorCode": "SYS_LOGIN_FAIL", // 错误码,见第五节各节点表,照抄 + "errorSummary": "验证码识别失败", // 人能看懂的一句话 + "retryable": true, // 系统类错误填 true(状态中心可安排重试);业务类填 false + "routeRole": "OPS" // 转给谁:OPS(运维)/PLANNER(计划员)/PURCHASE(采购) +} +``` +返回 `{"ok": true, "job_status": "..."}`。 + +**状态中心对 fail 的处理逻辑(你要知道,免得填错)**: +- `errorCode == "M5_QC_MISSING"` → 无条件 `FAILED_MANUAL`(转人工手工做,**不重试、不等质管**),`retryable` 填什么都没用。 +- 否则 `retryable=true` → `FAILED_RETRYABLE`(系统类,等重试)。 +- 否则 → `WAITING_USER`(业务类,进人工待办,按 `routeRole` 派人)。 + +> 判断原则:**系统能自愈的填 retryable=true**(登录超时、页面卡顿);**要人来决定的填 false**(对码要选、合同被禁、返利重复编码)。 + +## 四、businessRefs 业务编号回写(三把钥匙 + 收货单) + +有三个节点**必须**在 complete 时回传对应编号,否则状态中心返回 400 拒绝记成功: + +| 节点 nodeCode | 必传字段 | 是什么 | +| --- | --- | --- | +| CONTRACT_CREATE | `contractSystemNo` | 🔑合同编号(后续返利、M5 靠它串) | +| REBATE_HEADER | `rebateOrderNo` | 返利中心总单号 | +| M5_RECEIPT_NOTICE | `receiptNoticeNo` | 收货通知单号(全链终点) | + +其余节点若产出编号也可回传(如 M5 单号 `m5OrderNo`),状态中心会落库。字段名**照抄**,大小写别改。 + +## 五、逐节点你要做什么(页面操作 + 回调) + +> 完整版看 `资料/采购主链RPA一期实施与验收规范.md` 第四部分,每节点都有"页面操作步骤 + 代码验收 + 业务验收 + 失败去向"。这里给精简对照,让你知道每步调哪个回调、错误码填什么。 + +节点编排一共 **13 个 = 11 主节点 + 2 条件节点**(状态中心的 NODE_ORDER): +`ORDER_FETCH → OCR_EXTRACT → PRODUCT_MATCH → SOURCE_ADD → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT → REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER → M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB` + +- **2 个条件节点会被自动跳过**(你收不到 next_node 就是被跳了,别当 bug): + - `OCR_EXTRACT`:只有线下手工单才走,线上单(医药城/小药药)自动跳过。 + - `SOURCE_ADD`:只有对码判定缺货源才走,货源齐全自动跳过。 +- **不是你点页面的节点**:`PRODUCT_MATCH`(对码=状态中心内部算)、`SOURCE_ADD`/`M5_MANUAL_CONFIRM`/`M5_RECEIPT_NOTICE`(一期人工)、`ARCHIVE_JOB`(状态中心归档)。 +- 你负责的是下面标"★你的活"的节点。**顺序永远听 complete 返回的 next_node**,别自己写死(条件节点跳过就是状态中心帮你算好的)。 + +> 状态中心对回调做了**幂等 + 乱序拒绝**: +> - 同一节点同 attemptNo 重复 complete,不会二次推进(会返回上次的 next_node,带 `"idempotent": true`),网络重发放心。 +> - 回调的节点若不是该 job 的当前节点,返回 **409**(乱序被拒)。别跳着节点调,照 next_node 走。 +> - complete 可带 `idempotencyKey`(你生成的唯一串),便于状态中心去重。 + +### ① ORDER_FETCH · 订单获取 ★你的活 + +页面(医药城 / 小药药):登录 → 我的订单 → 选日期(默认前一天)→【待确认:按订单状态过滤】→ 导出订单明细 Excel → 存证据目录 + 截图。 +- complete:`summary` 带 `orderCount / supplierList / orderNoList`,`businessRefs` 无。 +- 当天无订单:正常结束(不算失败)。 +- fail 错误码:登录失败 `SYS_LOGIN_FAIL`(retryable=true, OPS)、页面超时 `SYS_PAGE_TIMEOUT`(true, OPS)、导出按钮改版找不到 `UI_ELEMENT_NOT_FOUND`(false, OPS)。 + +### ①b OCR_EXTRACT · 线下手工单识别(仅线下单) + +不是你主导。线上订单直接跳过此节点(complete 空过)。线下单由 OCR 服务识别,你只负责取件/触发。 +- fail:图片模糊 `BIZ_OCR_LOW_CONF`(false, PLANNER)、OCR服务挂 `SYS_OCR_DOWN`(true, OPS)。 + +### ③ CONTRACT_CREATE · 合同建单 ★你的活 + +页面(合同系统 `contract.gzmpc.com`):登录 → 按供应商对照表判断模板(格式/非格式承兑)→ 可复制历史合同再改 → 填基础信息,**签约日期/合同开始日期 = 订单日期(硬约束,不是今天)** → 填货品明细 → 填付款信息 → 提交送审。 +- **医药城分支**:下载平台购销合同,**同供应商同日合并成一个PDF**再上传(先合并!传第二份会覆盖第一份)。 +- **小药药分支**:格式合同,**跳过合同证文上传**。 +- complete:**必传 `businessRefs.contractSystemNo`(合同编号)**。 +- fail 错误码:供应商合同被禁/失效 `BIZ_CONTRACT_DISABLED`(false, PURCHASE)、PDF合并不支持 `CAP_PDF_MERGE_UNSUPPORTED`(false, OPS)、页面改版 `UI_ELEMENT_NOT_FOUND`(false, OPS)。 + +### ④ CONTRACT_APPROVE_WAIT · 等合同审批 ★你的活(定时查一次,不连续轮询) + +合同送审(CONTRACT_CREATE complete)后,状态中心把该 job 挂成 `WAITING_EXTERNAL`,并按业务方案每约半小时排一次查。**这个节点不用 start/complete/fail 三件套**,用下面两个专用接口: + +1. **到点扫待查**:你(或调度器)定时 `GET /api/scheduler/due-contract-checks`(带 X-Agent-Token),拿到到点该查的 job 列表(含 `contractSystemNo`)。 +2. **登录查一次审批状态**,把结果回给状态中心: + ``` + POST /api/jobs/{jobId}/nodes/CONTRACT_APPROVE_WAIT/check-result + { "approved": true, "operator": "agent", "detail": "已审批通过" } + ``` + - `approved=true` → 状态中心放行,返回 `{"ok":true,"next_node":"REBATE_HEADER"}`,你接着跑返利。 + - `approved=false` → 状态中心把 `wait_check_count+1`,排下一次查(约半小时后又出现在待查列表);查满上限(默认 8 次≈4 小时)还没过 → 自动升级 OPS(`WAITING_USER`),你不用管了。 +- **别死等、别连续轮询**:只在到点被扫到时查一次。 +- 注意:未审完也会有合同编号,但**必须审批通过才能进返利导入**。 + +### ⑤ REBATE_HEADER · 返利中心总单 ★你的活 + +页面(返利中心 `flgzmpc.com`):登录 → 采购协议管理 → **按合同编号查总单** → 填必填项、勾必选项 → 保存。 +- complete:**必传 `businessRefs.rebateOrderNo`(总单号)**。 +- fail:查不到总单/必填缺失 → OPS 或 PURCHASE 核查。 + +### ⑥ REBATE_IMPORT · 返利细单导入 ★你的活 + +页面:下载细单模板 → 填字段(操作码/货品编码、数量、单价、税率、承付方式、细单开始时间=订单时间、失效日期、结算方式、**特定补偿=0**、**细单备注=订单号**、货源信息)→ 上传导入 → 读成功/失败行。 +- complete:`summary` 带成功/失败计数与明细。 +- fail:重复编码(订单已做过)`REBATE_DUP_CODE`(false, PURCHASE)→ **该单挂起,整批继续**(重建规则待确认,先转人工)。 + +### ⑦ REBATE_SUBMIT · 返利送审 ⭐决定成败⭐ ★你的活 + +页面:在返利中心对该总单点"提交送审"(送审即自动审批通过,合同前端已审过)。 +- **反直觉核心:卡 M5 能不能带出品种的送审在返利中心,不在合同系统。这步不做,后面全废。** +- **未送审 = 硬阻断**,整个 job 停在这。不许跳过进 M5。 + +### ⑧ M5_CREATE_ORDER · M5 建采购单(1055)★你的活(M5 是桌面客户端) + +M5 是**桌面应用**(不是网页),用桌面自动化处理。步骤:采购订单管理 1055 → 新增 → 保管账=正常保管账(默认唯一)→ 隔天做则日期改前一天签约日期 → **备注填订单号** → 新增细单选品种(返利已送审的才带得出)→ 选对应数量 → 结算/接收/发票用返利带入值(**不填**)→ **★录完就停,绝不点保存★**。 +- complete:标记"M5待保存"(可回传 `m5OrderNo`)。 +- fail:质管资料缺失 `M5_QC_MISSING`(**填什么状态中心都转 FAILED_MANUAL,不重试不等质管,转人工手工做**)。 + +### ⑨⑩ M5_MANUAL_CONFIRM / M5_RECEIPT_NOTICE · 一期人工 + +一期这两步**人工做**(检查→保存→签订→生成收货通知单)。你不点。阶段二 `auto_save_enabled` 放开后才可能给你。 + +## 六、硬规则(红线,踩了就是错) + +1. **签约日期 = 订单日期**,不是今天。合同建单、M5 日期都按订单日期。 +2. **医药城同供应商同日合同合并成一个PDF再上传**(传第二份会覆盖);**小药药不上传合同证文**。 +3. **关键送审在返利中心(REBATE_SUBMIT)**,不是合同系统。不送审 M5 带不出协议,硬阻断。 +4. **M5 录完不点保存**(一期人工把关保存/签订)。auto_save_enabled 一期=0。 +5. **M5 质管缺失(M5_QC_MISSING)不重试、不等质管**,直接转人工。RPA 晚上跑,质管不会半夜维护。 +6. **返利细单:特定补偿=0、细单备注=订单号**(物流/结算要看)。细单开始时间=订单时间。 +7. **对码未命中/返利重复编码 = 单条挂起,整批继续**,不是停整批。 +8. **产出编号的三个节点(合同/返利总单/收货通知)complete 必传对应 businessRefs**,否则被 400 拒。 +9. **每个回调必须带 X-Agent-Token**;别把状态中心接口无认证暴露。 +10. **节点顺序听状态中心的**(照 complete 返回的 next_node 走),别自己写死。 + +## 七、你不做什么(边界) + +- **不碰状态存储/对码算法/前端页面**——那是状态中心(Trae)的活。你只点外部系统页面 + 回调。 +- **不做对码判断**——对码是状态中心内部算,MULTI/NONE 会进人工待办,不经过你。 +- **不做 M5 保存/签订/收货通知**(一期人工)。 +- **不做招财平台**(一期不纳入)。 +- 遇到文档标 `【待确认】` 的(订单状态过滤、返利重复编码重建、OCR部署方式),**别自己拍板**,留 TODO 问项目负责人。 + +## 八、联调建议顺序 + +1. 先跟状态中心开发对齐:实际 IP、正式 `X-Agent-Token`、证据目录路径。 +2. 拿一个测试供应商 + 一天的测试订单,先只跑 ORDER_FETCH:start → 导出 → complete,在状态中心 `GET /api/jobs/{id}` 看到轨迹即通。 +3. 再一节点一节点往后接,每接一个对照 `实施与验收规范` 第四部分的"业务验收"栏勾。 +4. 合同、返利、M5 三段分别单独联调通过后,再跑整链。 + + diff --git a/state_center_web/LAYOUT_PROPOSAL.md b/state_center_web/LAYOUT_PROPOSAL.md new file mode 100644 index 0000000..545b953 --- /dev/null +++ b/state_center_web/LAYOUT_PROPOSAL.md @@ -0,0 +1,207 @@ +# 状态中心页面 - 可调整版式方案 + +## 设计原则 + +1. **现在(联调阶段)**:全部展开,方便你看数据流转、验证接口。 +2. **后期(交付阶段)**:一键折叠技术细节,业务只看待办 + 简化流程图。 +3. **实现方式**:每个区域带展开/收起按钮 + 顶部"调试模式"开关,展开状态存 localStorage。 + +--- + +## 版式结构 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 顶栏 │ +│ [状态中心] [调试模式: ●开 ○关] 角色:采购 待办状态:待处理 │ +│ [令牌: ****] [刷新] │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 待办队列 (始终可见) [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ ┌────────────┬────────────┬──────────────────────────────────┐ │ +│ │ □ 新增货源 │ 对码待确认 │ M5人工确认 │ │ +│ │ 重庆亿昊 │ 阿卡波糖 │ 华润医药 │ │ +│ │ 10:00 │ 09:30 │ 昨天 │ │ +│ └────────────┴────────────┴──────────────────────────────────┘ │ +│ │ +│ 当前选中待办:新增货源 - 重庆亿昊药业 │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ 原因:该供应商未维护这些品种的货源 │ │ +│ │ [选择候选货品: ▼ 阿卡波糖片 50mg*30片] │ │ +│ │ [☑ 保存为下次自动匹配规则] │ │ +│ │ [确认并继续] [转派] [终止] │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 所属单据概览 [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ 单号: YYC-重庆亿昊药业-20260804-demo 状态: 等待人工 │ +│ 当前节点: 新增货源 责任角色: 采购 │ +│ 订单日期: 2026-08-04 供应商: 重庆亿昊药业 │ +│ │ +│ 业务编号链: │ +│ 订单 DD-001,DD-002 → 合同 HT-xxx → 返利 - (待填) → M5 - → 收货 - │ +│ │ +│ [查看完整详情 ↗] │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 流程状态图 [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ [订单获取✓] [OCR⊘] [对码✓] [新增货源●] [合同建单○] ... [归档○] │ +│ │ +│ 图例: ✓已完成 ●当前 ⊘已跳过 ○待处理 ✗失败 │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 调试:节点详情 (调试模式开启时显示) [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ 已执行节点: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ ORDER_FETCH [尝试1] 成功 09:40-09:42 (2分钟) │ │ +│ │ 摘要: {orderCount: 12} │ │ +│ │ 证据: orders.xlsx (SOURCE_EXCEL) │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ PRODUCT_MATCH [尝试1] 成功 09:50-09:51 (1分钟) │ │ +│ │ 摘要: {EXACT:10, ALIAS:1, MULTI:1} │ │ +│ │ 证据: 无 │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ SOURCE_ADD [尝试1] 等待人工 10:00- │ │ +│ │ 证据: 无 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 调试:证据包 (调试模式开启时显示) [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ ORDER_FETCH尝试1: │ +│ └─ SOURCE_EXCEL: /data/jobs/.../orders.xlsx (09:42) │ +│ CONTRACT_CREATE尝试1: │ +│ └─ CONTRACT_PDF: /data/jobs/.../contract.pdf (10:06) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 调试:单据列表 (调试模式开启时显示) [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ [全部] [执行中] [等待人工] [已完成] [失败] │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 重庆亿昊药业 2026-08-04 YYC 等待人工 新增货源 采购 │ │ +│ │ 华润医药 2026-08-03 线下 执行中 对码 计划员 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ ▼ 开发:节点回调模拟 (仅 DEV 环境显示) [收起/展开]│ +├──────────────────────────────────────────────────────────────────┤ +│ 当前节点: 新增货源 │ +│ [参数 JSON: ] │ +│ [开始] [成功] [失败] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 分区说明 + +### 1. 待办队列 (始终可见,不可收起) +- **用途**:主工作流,无论联调还是生产都需要。 +- **内容**:待办卡片 + 当前待办处理表单。 + +### 2. 所属单据概览 (默认展开,可收起) +- **用途**:提供当前待办的上下文(哪个供应商、哪天的单、跑到哪了)。 +- **后期**:业务只看这个 + 待办队列就够了。 + +### 3. 流程状态图 (默认展开,可收起) +- **用途**:直观看 13 个节点的完成情况。 +- **后期**:保留,但可以简化成 mini 版(只显示名称 + 状态点)。 + +### 4. 调试:节点详情 (调试模式开启时显示) +- **用途**:验证每个节点的 started_at/ended_at/duration/output_json/error。 +- **后期**:关掉,业务不需要看技术细节。 + +### 5. 调试:证据包 (调试模式开启时显示) +- **用途**:验证 RPA 回调上传的证据是否落库、路径对不对。 +- **后期**:关掉或改成"证据总数"摘要。 + +### 6. 调试:单据列表 (调试模式开启时显示) +- **用途**:你在联调时快速切换不同单据验证状态流转。 +- **后期**:关掉或改成独立页面(管理员监控用)。 + +### 7. 开发:节点回调模拟 (仅 DEV 环境显示) +- **用途**:本地联调时模拟艺赛旗回调。 +- **后期**:生产环境 `import.meta.env.DEV=false` 自动隐藏。 + +--- + +## 技术实现 + +### 1. 展开/收起状态存储 +```typescript +// localStorage 存每个区域的展开状态 +const sectionStates = { + jobSummary: true, // 所属单据概览 + timeline: true, // 流程状态图 + nodeDetails: false, // 节点详情(调试模式控制) + evidence: false, // 证据包(调试模式控制) + jobList: false, // 单据列表(调试模式控制) +}; +localStorage.setItem('sectionStates', JSON.stringify(sectionStates)); +``` + +### 2. 调试模式开关 +```typescript +const [debugMode, setDebugMode] = useState( + localStorage.getItem('debugMode') === 'true' +); + +// 顶栏开关:切换时同步存储,并控制"调试:"前缀的区域显隐 +function toggleDebugMode() { + const next = !debugMode; + setDebugMode(next); + localStorage.setItem('debugMode', String(next)); +} +``` + +### 3. 可收起组件封装 +```typescript + + {/* 单据详情内容 */} + + + + {/* 节点列表 */} + +``` + +--- + +## 后期交付时的调整 + +你只需要: +1. **关闭调试模式**:顶栏开关点"关",所有"调试:"区域自动隐藏。 +2. **可选:收起流程图**:如果业务觉得太占屏幕,点收起按钮即可。 +3. **最终业务看到的**:待办队列 + 所属单据概览(可能 + 简化流程图),干净清爽。 + +--- + +## 你确认一下 + +这个方案是否满足你"现在全看、后期可关"的需求?如果可以,我直接动手: +1. 先封装 `CollapsibleSection` 组件。 +2. 把现有页面按上述分区重组。 +3. 加调试模式开关 + localStorage 持久化。 +4. 调整样式,让收起状态下不占垂直空间。 + +或者你对分区划分 / 默认展开状态有别的想法,直接说,我再调整。 diff --git a/state_center_web/README.md b/state_center_web/README.md new file mode 100644 index 0000000..3fdbc90 --- /dev/null +++ b/state_center_web/README.md @@ -0,0 +1,65 @@ +# 状态中心 Web 版 + +## 一键启动联调环境 + +在项目根目录执行: + +macOS/Linux: + +```bash +./.venv_test/bin/python scripts/dev.py start +``` + +Windows PowerShell: + +```powershell +.\.venv_test\Scripts\python.exe scripts\dev.py start +``` + +启动器会复用或创建 Docker MySQL 容器 `rpa-center-mysql`,然后启动 FastAPI 和 Vite。 + +```bash +python scripts/dev.py status +python scripts/dev.py stop +``` + +`status` 只有在 MySQL 端口、FastAPI `/health`(包含数据库探测)和 Vite 端口都正常时才返回成功。 + +## 手动启动 + +```bash +cd state_center_web +npm install +npm run dev +``` + +默认地址:`http://localhost:5173` + +后端另开一个终端: + +macOS/Linux: + +```bash +python -m pip install -r state_center/requirements.txt +DEV_MODE=true python -m uvicorn state_center.app.main:app --reload --port 8000 +``` + +Windows PowerShell: + +```powershell +$env:DEV_MODE = "true" +python -m pip install -r state_center\requirements.txt +python -m uvicorn state_center.app.main:app --reload --port 8000 +``` + +浏览器访问:`http://localhost:5173/`。同一套页面适用于 Windows 和 macOS;前端不会自行推导流程下一节点,所有节点状态都来自 FastAPI。 + +## 说明 + +- 页面全中文。 +- 后端可连时,直接读 `state_center` 的 FastAPI 接口。 +- 后端没起时,页面自动切到本地演示数据,仍可看到单据列表、流程状态图和待办面板。 +- 页面顶部会明确显示“本地演示数据”或“后端已连接”。 +- 对码待办可直接选择候选货品;人工节点按节点显示对应动作。 +- 节点回调模拟区只在开发环境构建中显示。 +- 开发令牌只保存在浏览器会话里,不会写进代码。 diff --git a/state_center_web/index.html b/state_center_web/index.html new file mode 100644 index 0000000..0308ff3 --- /dev/null +++ b/state_center_web/index.html @@ -0,0 +1,12 @@ + + + + + + 状态中心 + + +
+ + + diff --git a/state_center_web/package-lock.json b/state_center_web/package-lock.json new file mode 100644 index 0000000..5dabd8a --- /dev/null +++ b/state_center_web/package-lock.json @@ -0,0 +1,1750 @@ +{ + "name": "state-center-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "state-center-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.3", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.403", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/state_center_web/package.json b/state_center_web/package.json new file mode 100644 index 0000000..c79b1c6 --- /dev/null +++ b/state_center_web/package.json @@ -0,0 +1,24 @@ +{ + "name": "state-center-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "tsc --noEmit", + "test": "node --test --experimental-strip-types src/*.test.mjs" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.3", + "vite": "^5.4.8" + } +} diff --git a/state_center_web/src/App.tsx b/state_center_web/src/App.tsx new file mode 100644 index 0000000..27fcd11 --- /dev/null +++ b/state_center_web/src/App.tsx @@ -0,0 +1,907 @@ +import { useEffect, useMemo, useState } from 'react'; +import { requestJson, sessionTokenStore } from './api/client'; +import { JobAcceptanceChecklist } from './components/JobAcceptanceChecklist'; +import { JobList } from './components/JobList'; +import { JobEvidenceList } from './components/JobEvidenceList'; +import { JobNodeDetailList } from './components/JobNodeDetailList'; +import { JobTimeline } from './components/JobTimeline'; +import { TaskList } from './components/TaskList'; +import { + type JobArtifactRow, + type JobNodeRow, + buildNodeCallbackRequestBody, + actionLabel, + allowedTaskActions, + buildBusinessOverview, + buildNodeDetails, + formatNodeCallbackTemplate, + nodeLabel, + nodeCallbackActionLabel, + reasonLabel, + roleLabel, + jobStatusBucketLabel, + jobStatusBucketMatches, + type JobStatusBucket, + type NodeCallbackKind, + taskCandidates, + taskStatusLabel, + type JobRow, + type StateMachineMeta, + type TaskRow, + type TimelineNode, +} from './stateMachine'; + +type JobDetail = { + job: JobRow; + artifacts: JobArtifactRow[]; + nodes: JobNodeRow[]; + tasks: TaskRow[]; +}; + +type TaskSubmitResponse = { + ok: boolean; + next_node?: string | null; + job_status?: string | null; + idempotent?: boolean; +}; + +const devActionsEnabled = import.meta.env.DEV; +const demoJobId = 'YYC-重庆亿昊药业-20260804-demo'; +const demoMeta: StateMachineMeta = { + node_order: [ + 'ORDER_FETCH', + 'OCR_EXTRACT', + 'PRODUCT_MATCH', + 'SOURCE_ADD', + 'CONTRACT_CREATE', + 'CONTRACT_APPROVE_WAIT', + 'REBATE_HEADER', + 'REBATE_IMPORT', + 'REBATE_SUBMIT', + 'M5_CREATE_ORDER', + 'M5_MANUAL_CONFIRM', + 'M5_RECEIPT_NOTICE', + 'ARCHIVE_JOB', + ], + transitions: {}, + conditional_nodes: ['OCR_EXTRACT', 'SOURCE_ADD'], + human_node_roles: { + SOURCE_ADD: 'PURCHASER', + M5_MANUAL_CONFIRM: 'PURCHASER', + M5_RECEIPT_NOTICE: 'PURCHASER', + }, + node_executor: { + ORDER_FETCH: 'RPA', + OCR_EXTRACT: 'RPA', + PRODUCT_MATCH: 'STATE_CENTER', + SOURCE_ADD: 'HUMAN', + CONTRACT_CREATE: 'RPA', + CONTRACT_APPROVE_WAIT: 'RPA', + REBATE_HEADER: 'RPA', + REBATE_IMPORT: 'RPA', + REBATE_SUBMIT: 'RPA', + M5_CREATE_ORDER: 'RPA', + M5_MANUAL_CONFIRM: 'HUMAN', + M5_RECEIPT_NOTICE: 'HUMAN', + ARCHIVE_JOB: 'STATE_CENTER', + }, + node_labels: { + ORDER_FETCH: '订单获取', + OCR_EXTRACT: 'OCR识别', + PRODUCT_MATCH: '对码', + SOURCE_ADD: '新增货源', + CONTRACT_CREATE: '合同建单', + CONTRACT_APPROVE_WAIT: '合同审批等待', + REBATE_HEADER: '返利总单', + REBATE_IMPORT: '返利细单导入', + REBATE_SUBMIT: '返利送审', + M5_CREATE_ORDER: 'M5建单', + M5_MANUAL_CONFIRM: 'M5人工确认', + M5_RECEIPT_NOTICE: '收货通知单', + ARCHIVE_JOB: '归档', + }, + executor_labels: { + STATE_CENTER: '状态中心', + RPA: '艺赛旗执行', + HUMAN: '人工处理', + }, + status_labels: { + PENDING: '待处理', + RUNNING: '执行中', + SUCCESS: '已完成', + FAILED: '已失败', + WAITING_USER: '等待人工', + WAITING_EXTERNAL: '等待外部', + SKIPPED: '已跳过', + }, +}; +const demoJobs: JobRow[] = [ + { + job_id: demoJobId, + supplier_name: '重庆亿昊药业', + order_date: '2026-08-04', + source_type: 'YYC', + status: 'WAITING_USER', + current_node_code: 'SOURCE_ADD', + current_role: 'PURCHASER', + created_at: '2026-08-10 09:40:00', + updated_at: '2026-08-10 10:06:00', + }, + { + job_id: 'OFFLINE-华润医药-20260803-demo', + supplier_name: '华润医药', + order_date: '2026-08-03', + source_type: 'OFFLINE', + status: 'RUNNING', + current_node_code: 'PRODUCT_MATCH', + current_role: 'PLANNER', + created_at: '2026-08-10 08:20:00', + updated_at: '2026-08-10 08:45:00', + }, +]; +const demoTimeline: TimelineNode[] = [ + { node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' }, + { node_code: 'PRODUCT_MATCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'SOURCE_ADD', display_status: 'WAITING_USER', attempt_no: 1, status: 'WAITING_USER' }, + { node_code: 'CONTRACT_CREATE', display_status: 'PENDING' }, + { node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' }, + { node_code: 'REBATE_HEADER', display_status: 'PENDING' }, + { node_code: 'REBATE_IMPORT', display_status: 'PENDING' }, + { node_code: 'REBATE_SUBMIT', display_status: 'PENDING' }, + { node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' }, + { node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' }, + { node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' }, + { node_code: 'ARCHIVE_JOB', display_status: 'PENDING' }, +]; +const demoTasks: TaskRow[] = [ + { + task_id: 'TASK-001', + job_id: demoJobId, + node_code: 'SOURCE_ADD', + role_code: 'PURCHASER', + status: 'OPEN', + title: '新增货源:重庆亿昊药业', + reason_type: 'SOURCE_ADD', + created_at: '2026-08-10 10:00:00', + }, + { + task_id: 'TASK-002', + job_id: 'OFFLINE-华润医药-20260803-demo', + node_code: 'PRODUCT_MATCH', + role_code: 'PLANNER', + status: 'OPEN', + title: '对码待确认:阿卡波糖片', + reason_type: 'MATCH_MULTI', + product_match_id: 7, + candidates_json: [ + { + goods_id: 'G-001', + goods_code: 'YP-001', + common_name: '阿卡波糖片', + spec: '50mg*30片', + base_unit: '盒', + manufacturer: '示例制药', + approval_no: '国药准字H00000001', + }, + { + goods_id: 'G-004', + goods_code: 'YP-004', + common_name: '阿卡波糖片', + spec: '50mg*60片', + base_unit: '盒', + manufacturer: '示例制药', + approval_no: '国药准字H00000004', + }, + ], + created_at: '2026-08-10 09:30:00', + }, +]; +const demoJobDetailMap: Record = { + [demoJobId]: { + job: demoJobs[0], + artifacts: [ + { + node_code: 'ORDER_FETCH', + attempt_no: 1, + artifact_type: 'SOURCE_EXCEL', + storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/orders.xlsx', + file_hash: 'sha256:demo-order-excel', + visible_roles: ['PURCHASER', 'PLANNER'], + is_masked: false, + created_at: '2026-08-10 09:42:00', + }, + { + node_code: 'CONTRACT_CREATE', + attempt_no: 1, + artifact_type: 'CONTRACT_PDF', + storage_path: '/data/jobs/YYC-重庆亿昊药业-20260804-demo/contract.pdf', + file_hash: 'sha256:demo-contract-pdf', + visible_roles: ['PURCHASER'], + is_masked: false, + created_at: '2026-08-10 10:06:00', + }, + ], + nodes: [ + { node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'SOURCE_ADD', attempt_no: 1, status: 'WAITING_USER' }, + ], + tasks: demoTasks, + }, + [demoJobs[1].job_id]: { + job: demoJobs[1], + artifacts: [], + nodes: [ + { node_code: 'ORDER_FETCH', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'OCR_EXTRACT', attempt_no: 1, status: 'SKIPPED' }, + { node_code: 'PRODUCT_MATCH', attempt_no: 1, status: 'RUNNING' }, + ], + tasks: [ + { + task_id: 'TASK-003', + job_id: demoJobs[1].job_id, + node_code: 'PRODUCT_MATCH', + role_code: 'PLANNER', + status: 'OPEN', + title: '对码待确认:阿卡波糖片', + reason_type: 'MATCH_MULTI', + product_match_id: 7, + candidates_json: demoTasks[1].candidates_json, + created_at: '2026-08-10 09:30:00', + }, + ], + }, +}; + +export default function App() { + const [meta, setMeta] = useState(null); + const [jobs, setJobs] = useState([]); + const [selectedJobId, setSelectedJobId] = useState(''); + const [jobDetail, setJobDetail] = useState(null); + const [timeline, setTimeline] = useState([]); + const [tasks, setTasks] = useState([]); + const [selectedTaskId, setSelectedTaskId] = useState(''); + const [roleCode, setRoleCode] = useState('PURCHASER'); + const [taskStatus, setTaskStatus] = useState('OPEN'); + const [jobBucket, setJobBucket] = useState('ALL'); + const [token, setToken] = useState(sessionTokenStore.get()); + const [taskAction, setTaskAction] = useState('CONFIRM_AND_CONTINUE'); + const [selectedCandidateId, setSelectedCandidateId] = useState(''); + const [saveAsAlias, setSaveAsAlias] = useState(true); + const [receiptNoticeNo, setReceiptNoticeNo] = useState(''); + const [targetRole, setTargetRole] = useState('PURCHASER'); + const [callbackKind, setCallbackKind] = useState('complete'); + const [simPayloadText, setSimPayloadText] = useState(formatNodeCallbackTemplate('ORDER_FETCH', 'complete')); + const [notice, setNotice] = useState(''); + const [busy, setBusy] = useState(false); + const [demoMode, setDemoMode] = useState(false); + + async function loadJobs() { + if (demoMode) { + setJobs(demoJobs); + return; + } + const data = await requestJson<{ items: JobRow[] }>('/api/jobs'); + setJobs(data.items); + if (!selectedJobId && data.items[0]) { + setSelectedJobId(data.items[0].job_id); + } + } + + async function loadMeta() { + if (demoMode) { + setMeta(demoMeta); + return; + } + const data = await requestJson('/api/state-machine'); + setMeta(data); + } + + async function loadTasks() { + if (demoMode) { + const filtered = demoTasks.filter((task) => task.role_code === roleCode && task.status === taskStatus); + setTasks(filtered); + setSelectedTaskId((current) => + filtered.some((task) => task.task_id === current) ? current : (filtered[0]?.task_id || ''), + ); + return; + } + const data = await requestJson<{ items: TaskRow[] }>( + `/api/tasks/my?roleCode=${encodeURIComponent(roleCode)}&status=${encodeURIComponent(taskStatus)}`, + ); + setTasks(data.items); + setSelectedTaskId((current) => + data.items.some((task) => task.task_id === current) ? current : (data.items[0]?.task_id || ''), + ); + } + + async function loadJobDetail(jobId: string) { + if (demoMode) { + const detail = demoJobDetailMap[jobId] || demoJobDetailMap[demoJobId]; + setJobDetail(detail); + setTimeline( + detail.job.job_id === demoJobId + ? demoTimeline + : [ + { node_code: 'ORDER_FETCH', display_status: 'SUCCESS', attempt_no: 1, status: 'SUCCESS' }, + { node_code: 'OCR_EXTRACT', display_status: 'SKIPPED' }, + { node_code: 'PRODUCT_MATCH', display_status: 'RUNNING', attempt_no: 1, status: 'RUNNING' }, + { node_code: 'SOURCE_ADD', display_status: 'PENDING' }, + { node_code: 'CONTRACT_CREATE', display_status: 'PENDING' }, + { node_code: 'CONTRACT_APPROVE_WAIT', display_status: 'PENDING' }, + { node_code: 'REBATE_HEADER', display_status: 'PENDING' }, + { node_code: 'REBATE_IMPORT', display_status: 'PENDING' }, + { node_code: 'REBATE_SUBMIT', display_status: 'PENDING' }, + { node_code: 'M5_CREATE_ORDER', display_status: 'PENDING' }, + { node_code: 'M5_MANUAL_CONFIRM', display_status: 'PENDING' }, + { node_code: 'M5_RECEIPT_NOTICE', display_status: 'PENDING' }, + { node_code: 'ARCHIVE_JOB', display_status: 'PENDING' }, + ], + ); + return; + } + const detail = await requestJson(`/api/jobs/${jobId}`); + const tl = await requestJson<{ job: JobRow; timeline: TimelineNode[] }>(`/api/jobs/${jobId}/timeline`); + setJobDetail(detail); + setTimeline(tl.timeline); + } + + function applyDemoData(reason: string) { + setDemoMode(true); + setMeta(demoMeta); + setJobs(demoJobs); + setSelectedJobId(demoJobId); + setJobDetail(demoJobDetailMap[demoJobId]); + setTimeline(demoTimeline); + setTasks(demoTasks); + setSelectedTaskId(demoTasks[0]?.task_id || ''); + setNotice(reason); + } + + useEffect(() => { + void Promise.all([loadMeta(), loadJobs(), loadTasks()]).catch(() => { + applyDemoData('后端暂不可用,已切换到本地演示数据。'); + }); + }, []); + + useEffect(() => { + if (!selectedJobId) return; + void loadJobDetail(selectedJobId).catch((error) => { + if (!demoMode) { + applyDemoData('后端暂不可用,已切换到本地演示数据。'); + return; + } + setNotice(error instanceof Error ? error.message : '加载单据失败'); + }); + }, [selectedJobId, demoMode]); + + useEffect(() => { + void loadTasks().catch((error) => { + if (!demoMode) { + applyDemoData('后端暂不可用,已切换到本地演示数据。'); + return; + } + setNotice(error instanceof Error ? error.message : '加载待办失败'); + }); + }, [roleCode, taskStatus, demoMode]); + + const selectedTask = useMemo(() => tasks.find((task) => task.task_id === selectedTaskId) || null, [tasks, selectedTaskId]); + const visibleJobs = useMemo( + () => jobs.filter((job) => jobStatusBucketMatches(jobBucket, job.status)), + [jobs, jobBucket], + ); + const selectedTaskActions = useMemo( + () => (selectedTask ? allowedTaskActions(selectedTask) : []), + [selectedTask], + ); + const candidates = useMemo(() => taskCandidates(selectedTask), [selectedTask]); + const selectedCandidate = useMemo( + () => candidates.find((candidate) => candidate.goods_id === selectedCandidateId) || null, + [candidates, selectedCandidateId], + ); + const currentNodeCode = jobDetail?.job.current_node_code || ''; + const callbackEndpoint = jobDetail && currentNodeCode + ? `/api/jobs/${jobDetail.job.job_id}/nodes/${currentNodeCode}/${callbackKind}` + : ''; + const jobArtifacts = jobDetail?.artifacts || []; + const jobNodeDetails = useMemo( + () => buildNodeDetails(timeline, jobDetail?.nodes || [], jobArtifacts, jobDetail?.tasks || []), + [timeline, jobDetail?.nodes, jobArtifacts, jobDetail?.tasks], + ); + const businessOverview = useMemo( + () => + jobDetail + ? buildBusinessOverview( + jobDetail.job, + timeline, + jobDetail.tasks, + jobArtifacts, + meta, + jobDetail.nodes, + ) + : null, + [jobDetail, timeline, jobArtifacts, meta], + ); + + useEffect(() => { + if (!visibleJobs.length) return; + if (!visibleJobs.some((job) => job.job_id === selectedJobId)) { + setSelectedJobId(visibleJobs[0].job_id); + } + }, [visibleJobs, selectedJobId]); + + useEffect(() => { + setTaskAction((current) => + selectedTaskActions.includes(current) ? current : (selectedTaskActions[0] || ''), + ); + }, [selectedTaskActions]); + + useEffect(() => { + setSelectedCandidateId((current) => + candidates.some((candidate) => candidate.goods_id === current) + ? current + : (candidates[0]?.goods_id || ''), + ); + }, [candidates]); + + useEffect(() => { + if (!currentNodeCode) return; + setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind)); + }, [currentNodeCode, callbackKind]); + + async function saveToken(nextToken: string) { + setToken(nextToken); + sessionTokenStore.set(nextToken); + } + + async function submitTask() { + if (!selectedTask) { + setNotice('请先选择一条待办。'); + return; + } + if (!token.trim()) { + setNotice('请先填写开发令牌。'); + return; + } + if (demoMode) { + setNotice(`演示提交:${actionLabel(taskAction)} 已记录。`); + return; + } + let payload: Record = {}; + if (taskAction === 'SELECT_CANDIDATE') { + if (!selectedCandidate || !selectedTask.product_match_id) { + setNotice('请选择一条候选货品。'); + return; + } + payload = { + productMatchId: selectedTask.product_match_id, + goodsId: selectedCandidate.goods_id, + goodsCode: selectedCandidate.goods_code, + baseUnit: selectedCandidate.base_unit, + saveAsAlias, + }; + } else if (taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE') { + if (!receiptNoticeNo.trim()) { + setNotice('请填写收货通知单号。'); + return; + } + payload = { receiptNoticeNo: receiptNoticeNo.trim() }; + } else if (taskAction === 'TRANSFER_MANUAL') { + payload = { targetRole }; + } + setBusy(true); + try { + const result = await requestJson(`/api/tasks/${selectedTask.task_id}/submit`, { + method: 'POST', + token, + body: { + action: taskAction, + payload, + submitToken: crypto.randomUUID(), + operator: 'web', + }, + }); + setNotice(`待办已提交:${result.idempotent ? '重复提交' : '首次提交'}。`); + await Promise.all([loadJobDetail(selectedTask.job_id), loadTasks(), loadJobs()]); + } catch (error) { + setNotice(error instanceof Error ? error.message : '提交失败'); + } finally { + setBusy(false); + } + } + + function resetNodeCallbackTemplate() { + if (!currentNodeCode) return; + setSimPayloadText(formatNodeCallbackTemplate(currentNodeCode, callbackKind)); + } + + async function sendNodeCallback() { + if (!jobDetail) { + setNotice('请先选择一条单据。'); + return; + } + if (!token.trim()) { + setNotice('请先填写开发令牌。'); + return; + } + if (demoMode) { + setNotice(`演示回调:${nodeCallbackActionLabel(callbackKind)} 已记录。`); + return; + } + let payload: unknown; + try { + payload = simPayloadText.trim() ? JSON.parse(simPayloadText) : {}; + } catch { + setNotice('联调回调参数不是合法 JSON。'); + return; + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + setNotice('联调回调参数必须是 JSON 对象。'); + return; + } + const body = buildNodeCallbackRequestBody( + callbackKind, + currentNodeCode, + payload as Record, + crypto.randomUUID(), + ); + setBusy(true); + try { + const result = await requestJson(callbackEndpoint, { + method: 'POST', + token, + body, + }); + const nextNodeText = result.next_node ? `,下一节点:${result.next_node}` : ''; + const statusText = result.job_status ? `,状态:${result.job_status}` : ''; + setNotice(`联调回调成功:${nodeCallbackActionLabel(callbackKind)}${nextNodeText}${statusText}`); + await Promise.all([loadJobDetail(jobDetail.job.job_id), loadJobs(), loadTasks()]); + } catch (error) { + setNotice(error instanceof Error ? error.message : '联调回调失败'); + } finally { + setBusy(false); + } + } + + function selectTaskFromNode(taskId: string) { + setTaskStatus('OPEN'); + setSelectedTaskId(taskId); + window.setTimeout(() => { + document.getElementById('task-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } + + async function handleArtifactClick(storagePath: string) { + try { + await navigator.clipboard.writeText(storagePath); + setNotice(`证据路径已复制:${storagePath}`); + } catch { + setNotice(`证据路径:${storagePath}`); + } + } + + return ( +
+
+
+
采购主链自动化一期
+

状态中心

+
+ {demoMode ? '本地演示数据' : '后端已连接'} +
+
+
+ + + + +
+
+ + {notice ?
{notice}
: null} + +
+ + +
+
+
+
+
单据概览
+

{businessOverview?.header.supplierName || '请选择一条单据'}

+
+ {businessOverview + ? `${businessOverview.header.jobId} · ${businessOverview.header.orderDateText} · ${businessOverview.header.sourceTypeText}` + : '先从左侧选择一条单据。'} +
+
+
+
{businessOverview?.header.currentStatusText || '-'}
+
当前节点:{businessOverview?.header.currentNodeText || '-'}
+
责任角色:{businessOverview?.header.currentRoleText || '-'}
+
+
+ +
+
+
开放待办
+
{businessOverview?.summary.openTaskCount ?? 0}
+
+
+
证据
+
{businessOverview?.summary.evidenceCount ?? 0}
+
+
+
节点
+
{businessOverview?.summary.nodeDetailCount ?? 0}
+
+
+
关键编号
+
{businessOverview?.summary.referenceDoneCount ?? 0}/5
+
+
+ +
+ {businessOverview?.referenceChain.map((item) => ( +
+
{item.label}
+
{item.value}
+
{item.status === 'DONE' ? '已回填' : '待回填'}
+
+ )) ||
请选择一条单据。
} +
+ +
+
当前待办
+ {businessOverview?.primaryTask ? ( +
+
{businessOverview.primaryTask.title}
+
+ {businessOverview.primaryTask.nodeText} · {businessOverview.primaryTask.roleText} · {businessOverview.primaryTask.statusText} +
+
{businessOverview.primaryTask.reasonText}
+
+ ) : ( +
当前没有未完成待办。
+ )} +
+
+ + + +
+
+ +
+ +
+
+
待办提交
+ {selectedTask ? ( +
+
+
当前待办
+
{selectedTask.title}
+
+
+
处理原因
+
{selectedTask.reason_user_friendly || reasonLabel(selectedTask.reason_type)}
+
+
+ ) : ( +
请先选择一条待办。
+ )} + + {selectedTask ? ( +
+ + + {taskAction === 'SELECT_CANDIDATE' ? ( + <> + + + {selectedCandidate ? ( +
+ 货品编码:{selectedCandidate.goods_code || '-'} + 生产厂家:{selectedCandidate.manufacturer || '-'} + 批准文号:{selectedCandidate.approval_no || '-'} + 基本单位:{selectedCandidate.base_unit || '-'} +
+ ) : ( +
当前待办没有候选货品。
+ )} + + ) : null} + + {taskAction === 'CONFIRM_AND_CONTINUE' && selectedTask.node_code === 'M5_RECEIPT_NOTICE' ? ( + + ) : null} + + {taskAction === 'TRANSFER_MANUAL' ? ( + + ) : null} + +
+ +
+
+ ) : null} + +
+ 当前筛选:{roleLabel(roleCode)},{taskStatusLabel(taskStatus)} +
+
+
+
+ +
+
+ + 证据包 + {businessOverview?.summary.evidenceCount ?? 0} + + void handleArtifactClick(path)} /> +
+ +
+ + 节点详情 + {businessOverview?.summary.nodeDetailCount ?? 0} + + void handleArtifactClick(path)} + /> +
+ +
+ + 验收对照 + + {businessOverview + ? `${businessOverview.summary.acceptanceCounts.DONE}/${ + businessOverview.summary.acceptanceCounts.DONE + + businessOverview.summary.acceptanceCounts.PARTIAL + + businessOverview.summary.acceptanceCounts.TODO + + businessOverview.summary.acceptanceCounts.NA + }` + : '0/0'} + + + +
+ + {devActionsEnabled ? ( +
+ + 联调回调 + DEV + +
+
+ +
+
请求路径
+
{callbackEndpoint || '-'}
+
+
+
+