Compare commits

...

4 Commits

Author SHA1 Message Date
Yuuuu 63eec40334 Merge branch 'feat/contract-create-ui'
YiSaiQi CONTRACT_CREATE studio project, Selenium flow, and canvas artifacts.
2026-08-31 11:02:50 +08:00
34BFF76918A9449ABAB323149DDCEA73 94db441de0 feat: add YiSaiQi studio project for CONTRACT_CREATE on other machines 2026-08-31 10:22:10 +08:00
34BFF76918A9449ABAB323149DDCEA73 5824279fd5 feat: complete CONTRACT_CREATE page flow with Selenium adapter 2026-08-31 10:18:24 +08:00
34BFF76918A9449ABAB323149DDCEA73 ca79659e3e feat: add Windows Selenium runner for CONTRACT_CREATE 2026-08-13 15:26:34 +08:00
33 changed files with 4965 additions and 374 deletions

6
.gitignore vendored
View File

@ -21,6 +21,12 @@ state_center_web/dist/
# Local runtime / agent # Local runtime / agent
.runtime/ .runtime/
rpa_yisaiqi/screenshots/
rpa-chrome-profile/
**/chrome-profile/
.tmp/
.grok/
.grokignore
.claude/ .claude/
.superpowers/ .superpowers/

View File

@ -57,10 +57,20 @@ python -m rpa_yisaiqi.contract_create_node `
## 真实合同起草运行 ## 真实合同起草运行
先准备合同起草输入 JSON,参考: 客商必须用合同测试系统里**真实存在**的操作码。`CUST-YYC-001` 这类占位符会在选客商时搜不到,入参校验会直接拒绝。
- `examples/contract_draft_yyc.json` 测试环境当前可用:
- `examples/contract_draft_xyy.json`
| 字段 | 值 |
| --- | --- |
| customerCode | `DW0100046361` |
| customerName | `重庆亿昊药业有限公司` |
联调入参用:
- `examples/contract_create_input_dry_run.json`:真客商 + `dry_run`,先校准页面
- `examples/contract_draft_yyc.json`:医药城,同上客商
- `examples/contract_draft_xyy.json`:小药药分支(不上传 PDF),客商仍用合同系统里能搜到的同一条
YYC 有多份平台 PDF 时需要合并,先安装: YYC 有多份平台 PDF 时需要合并,先安装:
@ -75,7 +85,7 @@ python -m rpa_yisaiqi.contract_create_node `
--state-center-url http://192.168.x.x:8000 ` --state-center-url http://192.168.x.x:8000 `
--token dev-agent-token ` --token dev-agent-token `
--job-id <状态中心job_id> ` --job-id <状态中心job_id> `
--supplier-name 重庆亿昊药业 ` --supplier-name 重庆亿昊药业有限公司 `
--order-date 2026-08-11 ` --order-date 2026-08-11 `
--source-type YYC ` --source-type YYC `
--draft-path rpa_yisaiqi/examples/contract_draft_yyc.json ` --draft-path rpa_yisaiqi/examples/contract_draft_yyc.json `
@ -111,6 +121,27 @@ python -m rpa_yisaiqi.contract_create_node `
--state-center-url http://192.168.x.x:8000 --state-center-url http://192.168.x.x:8000
``` ```
## 艺赛旗画布怎么看
Studio 里「插入代码块」标题改不了,会一直显示「插入代码块#1」。步骤名写在节点**备注**里,画布上出现在代码块下方,例如 `S06 填写签约方`。
流程按四段换行,不要再排成一条长龙:
```
准备 开始 → S01 初始化 → S02 打开系统 → S03 登录
进入起草 S04 进入起草 → S05 选择模板
填写表单 S06 签约方 → S07 基础信息 → S08 金额日期 → S09 货品 → S10 付款
提交回写 S11 上传PDF → S12 提交 → S13 读合同号 → S14 回调
```
改步骤后先关掉 Studio,再生成画布:
```powershell
python rpa_yisaiqi/build_multinode_seq.py
```
然后重新打开 `rpa_yisaiqi/studio` 项目并编译。
## 接入真实页面自动化 ## 接入真实页面自动化
真实艺赛旗页面操作按流程图拆在: 真实艺赛旗页面操作按流程图拆在:

View File

@ -0,0 +1,17 @@
# coding=utf-8
"""多节点流程图(Main.seq)的跨节点共享状态容器。
艺赛旗编译 Main.seq 后,各代码节点的 PBCode 会在同一 Python 进程中执行。
本模块以「模块级全局变量」承载 browser / ctx / client / spec 等对象,
避免依赖艺赛旗对局部变量的拼接方式,让每个节点都能可靠地取到上一步的产物。
"""
spec = None
client = None
ctx = None
browser = None
submitted = False
contract_no = ""
fd_id = ""
is_dry_run = False
completed = False

View File

@ -0,0 +1,505 @@
# coding=utf-8
"""
APA (艺赛旗) Selenium Chrome BrowserAdapter 实现
通过 Selenium WebDriver 直接操控 Chrome,不依赖 APA 浏览器模块
"""
from __future__ import annotations
import os
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try:
path.resolve().relative_to(repo_root.resolve())
except ValueError:
return False
return True
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
"""Chrome user-data dir must stay outside the repo so list_dir never walks 3GB."""
root = (repo_root or REPO_ROOT).resolve()
outside = (root.parent / "rpa-chrome-profile").resolve()
raw = os.getenv("RPA_CHROME_PROFILE") if requested is None else requested
if raw:
candidate = Path(raw).expanduser().resolve()
if _is_inside_repo(candidate, root):
print(f"RPA_CHROME_PROFILE 指向仓库内 {candidate},已改用 {outside}", flush=True)
return outside
return candidate
return outside
class ApaBrowserAdapter:
"""Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
def __init__(self, url: str = "", *, flow: str = "default"):
self._url = url
self._flow = flow
self._driver = None
self._is_open = False
def _get_driver(self):
"""创建或获取 Chrome WebDriver"""
if self._driver is not None:
return self._driver
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
profile_base = resolve_chrome_profile_base()
if _is_inside_repo(profile_base, REPO_ROOT):
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}")
# 每次进程单独目录:上次 detach 留下的 Chrome 会锁住同一 profile
profile_dir = profile_base / str(os.getpid())
os.makedirs(profile_dir, exist_ok=True)
profile_dir = str(profile_dir)
options = Options()
options.page_load_strategy = "eager"
# 不自动关闭浏览器,方便调试
options.add_experimental_option("detach", True)
# 避免被检测为自动化
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--disable-gpu")
options.add_argument("--disable-extensions")
options.add_argument("--disable-popup-blocking")
options.add_argument("--start-maximized")
# 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住
options.add_argument("--user-data-dir=" + profile_dir)
chromedriver = os.getenv("CHROMEDRIVER_PATH")
if chromedriver and os.path.exists(chromedriver):
service = Service(chromedriver)
else:
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.set_page_load_timeout(45)
self._driver.set_script_timeout(30)
return self._driver
def _ensure_open(self):
if self._driver is None:
driver = self._get_driver()
if self._url:
driver.get(self._url)
self._is_open = True
# ---- 内部查找方法 ----
def _find(self, selector: str, timeout: int = 5):
"""
智能查找元素:支持 CSS、XPath、text=、label=、aria=、title=、:contains()
"""
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException
import re
# 1. text=xxx → 查找包含对应文本的元素
if selector.startswith("text="):
text_val = selector[5:].strip('"').strip("'")
xpath = f'//*[contains(text(),"{text_val}")]'
return self._driver.find_element(By.XPATH, xpath)
# 2. label=xxx → 找到对应标签的输入框
if selector.startswith("label="):
text_val = selector[6:].strip('"').strip("'")
xpath = f'//label[contains(text(),"{text_val}")]/following-sibling::*[1][self::input or self::select or self::textarea]'
try:
return self._driver.find_element(By.XPATH, xpath)
except NoSuchElementException:
# Fallback: find the label's "for" attribute
try:
for_id = self._driver.find_element(
By.XPATH, f'//label[contains(text(),"{text_val}")]'
).get_attribute("for")
if for_id:
return self._driver.find_element(By.ID, for_id)
except NoSuchElementException:
pass
raise
# 3. aria=xxx → aria-label 属性
if selector.startswith("aria="):
text_val = selector[5:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[aria-label="{text_val}"]')
# 4. title=xxx → title 属性
if selector.startswith("title="):
text_val = selector[6:].strip('"').strip("'")
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
# 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本"))
if ":contains(" in selector:
m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector)
if m:
tag_part, text_val = m.groups()
xpath = f'//{tag_part}[contains(.,"{text_val}")]'
return self._driver.find_element(By.XPATH, xpath)
# 6. 显式 XPath(以 / 或 // 开头)优先按 XPath 查找
if selector.startswith("//") or selector.startswith("/"):
return self._driver.find_element(By.XPATH, selector)
# 7. 标准 CSS selector
return self._driver.find_element(By.CSS_SELECTOR, selector)
# ---- BrowserAdapter 协议实现 ----
def open_url(self, url: str) -> None:
self._url = url
driver = self._get_driver()
try:
driver.get(url)
except Exception as exc:
# eager 仍可能因长轮询资源超时;页面文档已出来就继续
current = ""
try:
current = driver.current_url or ""
except Exception:
current = ""
if not current or current in ("data:,", "about:blank"):
raise RuntimeError(f"打开页面失败:{url} ({exc})") from exc
self._is_open = True
def _set_value_via_js(self, el, value: str) -> None:
"""通过原生 value setter + 事件注入设置值,兼容 LUI/React 受控组件"""
self._driver.execute_script(
"""
var el = arguments[0];
var value = arguments[1];
var proto = el.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('blur', { bubbles: true }));
""",
el,
str(value),
)
def fill_first(self, selectors: list, value: str) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
# 先尝试原生输入;LUI 受控组件用 JS 注入兜底
try:
el.clear()
el.send_keys(str(value))
except Exception:
pass
if el.get_attribute("value") != str(value):
self._set_value_via_js(el, str(value))
time.sleep(0.3)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"fill_first failed: {selectors}, last: {last_error}")
def click_native(self, selectors: list) -> None:
"""真实鼠标点击,给只监听 mousedown 的 LUI 页签/工具栏用。"""
self._ensure_open()
from selenium.webdriver.common.action_chains import ActionChains
last_error = None
for sel in selectors:
try:
el = self._find(sel)
self._driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'center'});",
el,
)
time.sleep(0.2)
ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform()
time.sleep(0.4)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"click_native failed: {selectors}, last: {last_error}")
def click_first(self, selectors: list) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
# LUI/委托事件用 JS 点击最可靠
self._driver.execute_script("arguments[0].click()", el)
time.sleep(0.5)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"click_first failed: {selectors}, last: {last_error}")
def double_click_first(self, selectors: list) -> None:
self._ensure_open()
from selenium.webdriver.common.action_chains import ActionChains
last_error = None
for sel in selectors:
try:
el = self._find(sel)
ActionChains(self._driver).double_click(el).perform()
time.sleep(0.5)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"double_click_first failed: {selectors}, last: {last_error}")
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
self._ensure_open()
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
try:
WebDriverWait(self._driver, timeout_seconds).until(
EC.text_to_be_present_in_element((By.TAG_NAME, "body"), text)
)
except Exception as e:
raise TimeoutError(f"wait_for_text '{text}' timed out: {e}")
def wait_for_element(self, selectors: list, timeout_seconds: int = 15) -> None:
self._ensure_open()
deadline = time.time() + timeout_seconds
last_error = None
while time.time() < deadline:
for sel in selectors:
try:
self._find(sel, timeout=1)
return
except Exception as e:
last_error = e
time.sleep(0.5)
raise TimeoutError(f"wait_for_element timed out: {selectors}, last: {last_error}")
def element_exists(self, selectors: list) -> bool:
self._ensure_open()
for sel in selectors:
try:
self._find(sel, timeout=1)
return True
except Exception:
continue
return False
def switch_to_frame(self, selectors: list) -> None:
"""切换到匹配到的 iframe。优先可见弹窗,避免切到上次留下的隐藏 iframe。"""
self._ensure_open()
from selenium.webdriver.common.by import By
last_error = None
found = []
for sel in selectors:
try:
if sel.startswith("//") or sel.startswith("/"):
found.extend(self._driver.find_elements(By.XPATH, sel))
else:
found.extend(self._driver.find_elements(By.CSS_SELECTOR, sel))
except Exception as e:
last_error = e
visible = []
hidden = []
for el in found:
try:
(visible if el.is_displayed() else hidden).append(el)
except Exception:
hidden.append(el)
for el in visible + hidden:
try:
self._driver.switch_to.default_content()
self._driver.switch_to.frame(el)
return
except Exception as e:
last_error = e
raise RuntimeError(f"switch_to_frame failed: {selectors}, last: {last_error}")
def switch_to_default_content(self) -> None:
"""切回主文档(顶层 frame)。"""
self._ensure_open()
self._driver.switch_to.default_content()
def execute_script(self, script: str, *args):
self._ensure_open()
return self._driver.execute_script(script, *args)
def select_xform(self, name: str, value: str) -> None:
"""选中 Landray xform fancy-select(.fs-wrap / .fs-option)。"""
self._ensure_open()
result = self._driver.execute_script(
"""
const name = arguments[0];
const wanted = String(arguments[1] || '').trim();
const flag = document.querySelector('xformflag[property="' + name + '"]');
if (!flag) {
return {ok: false, error: 'flag not found: ' + name};
}
const norm = (s) => String(s || '').replace(/\\s+/g, '').trim();
const wantedN = norm(wanted);
const opts = Array.from(flag.querySelectorAll('.fs-option'));
let target = opts.find((o) => {
const text = norm((o.querySelector('.fs-option-label') || o).textContent);
const val = norm(o.getAttribute('data-value'));
return text === wantedN || val === wantedN;
});
if (!target && wantedN.length >= 2) {
target = opts.find((o) => {
const text = norm((o.querySelector('.fs-option-label') || o).textContent);
return text.indexOf(wantedN) >= 0;
});
}
if (!target) {
return {
ok: false,
error: 'option not found: ' + wanted,
available: opts.map((o) => ((o.querySelector('.fs-option-label') || o).textContent || '').trim()),
};
}
const labelWrap = flag.querySelector('.fs-label-wrap');
if (labelWrap) { labelWrap.click(); }
target.click();
const input = flag.querySelector('input[name="' + name + '"]');
const selected = target.getAttribute('data-value') || '';
if (input) {
const proto = window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(input, selected);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
if (typeof __xformDispatch === 'function') {
try { __xformDispatch(selected, input); } catch (e) {}
}
}
return {
ok: true,
value: selected,
label: ((flag.querySelector('.fs-label') || {}).textContent || '').trim(),
};
""",
name,
str(value),
)
if not result or not result.get("ok"):
raise RuntimeError(f"select_xform failed: {name}={value} {result}")
time.sleep(0.3)
def select_first(self, selectors: list, value: str) -> None:
self._ensure_open()
from selenium.webdriver.support.ui import Select
last_error = None
for sel in selectors:
try:
el = self._find(sel)
# 原生 <select>
try:
Select(el).select_by_visible_text(value)
time.sleep(0.3)
if self._selected_text_matches(el, value):
return
except Exception:
pass
# 兜底:JS 定位 option 并设置选中
self._select_via_js(el, value)
time.sleep(0.3)
if self._selected_text_matches(el, value):
return
raise RuntimeError(f"select_first 未选中目标项: {value}")
except Exception as e:
last_error = e
continue
raise RuntimeError(f"select_first failed: {selectors}, last: {last_error}")
def _selected_text_matches(self, el, value: str) -> bool:
try:
script = (
"var el = arguments[0];"
"if (el.selectedIndex != null) {"
" var o = el.options[el.selectedIndex];"
" return o ? (o.textContent || o.text || '').trim() : '';"
"}"
"return (el.value || '').trim();"
)
selected = self._driver.execute_script(script, el)
return (selected or "").strip() == str(value).strip()
except Exception:
return False
def _select_via_js(self, el, value: str) -> None:
self._driver.execute_script(
"""
var el = arguments[0];
var value = arguments[1];
var options = el.options || [];
for (var i = 0; i < options.length; i++) {
var t = (options[i].textContent || options[i].text || '').trim();
if (t === value || options[i].value === value) {
el.selectedIndex = i;
el.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
}
""",
el,
str(value),
)
def upload_file_first(self, selectors: list, file_path: str) -> None:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
el.send_keys(file_path)
time.sleep(1)
return
except Exception as e:
last_error = e
continue
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
def read_first(self, selectors: list, value_name: str = "") -> str:
self._ensure_open()
last_error = None
for sel in selectors:
try:
el = self._find(sel)
result = el.text or el.get_attribute("value") or ""
if result:
return result
except Exception as e:
last_error = e
continue
raise RuntimeError(f"read_first failed: {selectors}, last: {last_error}")
def read_body_text(self) -> str:
self._ensure_open()
try:
return self._driver.find_element("tag name", "body").text
except Exception:
return ""
def screenshot(self, name: str) -> str:
from .runtime.accept import artifact_dir
save_dir = artifact_dir(self._flow)
save_dir.mkdir(parents=True, exist_ok=True)
path = save_dir / f"{name}.png"
if self._driver:
self._driver.save_screenshot(str(path))
return str(path)

View File

@ -0,0 +1,358 @@
# coding=utf-8
"""
构建合同建单 Main.seq —— 按业务段换行,画布上能直接读出步骤。
艺赛旗「插入代码块」的标题固定为「插入代码块#1」,「业务流程描述」节点
打开后再保存标题会被清空。画布上真正能看见的是代码块的备注(NOTE)。
因此本脚本:
1. 不再插入空的业务流程描述节点
2. 把「S06 填写签约方」写进 NOTE,作为画布副标题
3. 按 准备 / 进入起草 / 填写表单 / 提交回写 四段换行
关闭 Studio 后运行本脚本,再重新打开项目编译。
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "studio" / "Main.seq"
COL_W = 280
ROW_H = 200
ORIGIN_X = 360
ORIGIN_Y = 220
BEGIN_TAG = "202608141000000001"
def b64_apa(val):
# 艺赛旗 EP_CONTROL / EP_Gen 字段值末尾带换行,缺失会导致字段解析失败
return json.dumps({"val": base64.b64encode(str(val).encode()).decode()}, indent=4) + "\n"
def b64_raw(val):
return base64.b64encode(str(val).encode()).decode()
STEP_INIT = '''import sys, os, json
from pathlib import Path
_REPO_ROOT = next(
p for p in Path(__file__).resolve().parents
if (p / "rpa_yisaiqi" / "__init__.py").exists()
)
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_create_node import ContractCreateError, load_input_data, build_spec_from_data
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
def _first(*values):
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
raw = (self.input_arg or "").strip()
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw)
if not raw:
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径")
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"), data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"), "http://192.168.105.69:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
_st.spec = build_spec_from_data(data)
_st.client = StateCenterClient(state_center_url, agent_token)
_st.ctx = _st.spec.flow_context
_st.browser = ApaBrowserAdapter(url=_st.ctx.contract_url, flow="CONTRACT_CREATE")
_st.is_dry_run = (_st.spec.submit_mode == "dry_run")
if not _st.is_dry_run:
print("回调状态中心 start", _st.spec.job_id)
_st.client.start_node(_st.spec.job_id, "CONTRACT_CREATE")'''
STEP_OPEN = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import open_contract_system
open_contract_system(_st.browser, _st.ctx)'''
STEP_LOGIN = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import login_contract_system
login_contract_system(_st.browser, _st.ctx)'''
STEP_ENTER = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import enter_contract_create
enter_contract_create(_st.browser)'''
STEP_TEMPLATE = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import select_contract_template
select_contract_template(_st.browser, _st.ctx)'''
STEP_SIGNATORY = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_signatory_info
fill_contract_signatory_info(_st.browser, _st.ctx)'''
STEP_BASIC = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info
fill_contract_basic_info(_st.browser, _st.ctx)'''
STEP_AMOUNT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_amount_and_dates
fill_contract_amount_and_dates(_st.browser, _st.ctx)'''
STEP_GOODS = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_goods_lines
fill_contract_goods_lines(_st.browser, _st.ctx)'''
STEP_PAYMENT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_payment_info
fill_contract_payment_info(_st.browser, _st.ctx)'''
STEP_UPLOAD = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import upload_contract_document_if_needed
upload_contract_document_if_needed(_st.browser, _st.ctx)'''
STEP_SUBMIT = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import submit_contract_for_approval
_st.submitted = submit_contract_for_approval(_st.browser, _st.ctx)'''
STEP_READ_NO = '''import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import read_contract_number
if _st.submitted:
_st.contract_no = read_contract_number(_st.browser, _st.ctx)'''
STEP_COMPLETE = '''import json
import rpa_yisaiqi._flow_state as _st
if not _st.is_dry_run and _st.contract_no:
print("回调状态中心 complete", _st.spec.job_id, _st.contract_no)
_st.client.complete_node(_st.spec.job_id, "CONTRACT_CREATE", {
"businessRefs": {"contractSystemNo": _st.contract_no},
})
print(json.dumps({"status": "done", "contractSystemNo": _st.contract_no}, ensure_ascii=False))'''
# 每段一行。NOTE 会显示在画布「插入代码块#1」下方,务必写短步骤名。
PHASES = [
(
"准备",
[
("S01 初始化并回调 start", STEP_INIT),
("S02 打开合同系统", STEP_OPEN),
("S03 登录合同系统", STEP_LOGIN),
],
),
(
"进入起草",
[
("S04 进入合同起草", STEP_ENTER),
("S05 选择模板", STEP_TEMPLATE),
],
),
(
"填写表单",
[
("S06 填写签约方", STEP_SIGNATORY),
("S07 填写基础信息", STEP_BASIC),
("S08 填写金额日期", STEP_AMOUNT),
("S09 填写货品明细", STEP_GOODS),
("S10 填写付款信息", STEP_PAYMENT),
],
),
(
"提交回写",
[
("S11 上传合同PDF", STEP_UPLOAD),
("S12 提交送审", STEP_SUBMIT),
("S13 读取合同编号", STEP_READ_NO),
("S14 回调 complete", STEP_COMPLETE),
],
),
]
def code_tag(i: int) -> str:
return f"202608143{i:02d}0000000"
def line_tag(i: int) -> str:
return f"202608149{i:02d}0000000"
def _iter_steps():
index = 1
for phase_name, items in PHASES:
for title, code in items:
yield index, phase_name, title, code
index += 1
def build_begin_node(px: int, py: int) -> dict:
return {
"NodePanelData": {
"ExpandedGroups": [],
"InputProperty": {"mouse_x": 0, "mouse_y": 0},
"PubProperty": "",
"RetPins": [],
},
"NodeProperty": "",
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": False,
"NodePx": str(px),
"NodePy": str(py),
"NodeSubType": "EnumWidget_Begin",
"NodeTag": BEGIN_TAG,
"NodeTitle": "开始",
},
}
def build_code_node(tag: str, title: str, code: str, px: int, py: int) -> dict:
body = f'print("[{title}]")\n' + code
return {
"NodePanelData": {
"ExpandedGroups": [],
"InputProperty": {"mouse_x": 0, "mouse_y": 0},
"PubProperty": "",
"RetPins": [],
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": json.dumps(
{"PImport": "", "PBCode": b64_raw(body)}, indent=4
) + "\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": b64_apa("0"),
"EP_Gen_BEFOREDELAY": b64_apa("0"),
"EP_Gen_NOTE": b64_apa(title),
},
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": False,
"NodePx": str(px),
"NodePy": str(py),
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": tag,
"NodeTitle": title,
},
}
def build_nodes():
nodes = []
placed = [] # (tag, x, y)
begin_x, begin_y = ORIGIN_X, ORIGIN_Y
nodes.append(build_begin_node(begin_x, begin_y))
placed.append((BEGIN_TAG, begin_x, begin_y))
# 第一段从「开始」右侧接着排;后续每段单独一行
col = 1
row = 0
current_phase = None
for index, phase_name, title, code in _iter_steps():
if current_phase is None:
current_phase = phase_name
elif phase_name != current_phase:
current_phase = phase_name
row += 1
col = 0
x = ORIGIN_X + col * COL_W
y = ORIGIN_Y + row * ROW_H
tag = code_tag(index)
nodes.append(build_code_node(tag, title, code, x, y))
placed.append((tag, x, y))
col += 1
return nodes, placed
def build_lines(placed: list) -> list:
lines = []
for k in range(len(placed) - 1):
prev_tag, prev_x, prev_y = placed[k]
next_tag, next_x, next_y = placed[k + 1]
same_row = prev_y == next_y
if same_row:
start_x, start_y = prev_x + 150, prev_y + 40
end_x, end_y = next_x - 4, next_y + 40
else:
start_x, start_y = prev_x + 70, prev_y + 90
end_x, end_y = next_x + 70, next_y - 8
lines.append({
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": f"{next_tag}/EPin_Flow_In",
"Px": str(end_x),
"Py": str(end_y),
},
"LineNote": "",
"LineSplit": {"Px": "", "Py": ""},
"LineStart": {
"Pidx": f"{prev_tag}/EPin_Flow_Out",
"Px": str(start_x),
"Py": str(start_y),
},
"LineTag": line_tag(k + 1),
"LineType": 0,
})
return lines
def main() -> None:
nodes, placed = build_nodes()
xs = [x for _, x, _ in placed]
ys = [y for _, _, y in placed]
main_seq = {
"LineList": build_lines(placed),
"NodeList": nodes,
"SeqTag": "Main",
"ViewCenterPx": str((min(xs) + max(xs)) // 2),
"ViewCenterPy": str((min(ys) + max(ys)) // 2),
"ViewScaling": "0.7",
}
OUT.write_text(json.dumps(main_seq, ensure_ascii=False, indent=4), encoding="utf-8")
print(f"多节点 Main.seq 已生成:{OUT}")
print(f"节点数:{len(nodes)}(1 开始 + {len(placed) - 1} 代码块,已去掉空业务标识)")
print(f"连线数:{len(main_seq['LineList'])}")
current_phase = None
for index, phase_name, title, _ in _iter_steps():
if phase_name != current_phase:
current_phase = phase_name
print(f" [{phase_name}]")
print(f" {title}")
if __name__ == "__main__":
main()

View File

@ -2,12 +2,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Callable from typing import Any, Callable, Dict
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import quote from urllib.parse import quote
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
JsonDict = dict[str, Any] JsonDict = Dict[str, Any]
Opener = Callable[[Request, float], Any] Opener = Callable[[Request, float], Any]

View File

@ -1,47 +1,66 @@
"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration. """CONTRACT_CREATE node: input building + state-center callback (runtime.shell) + Selenium drafting."""
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 from __future__ import annotations
import argparse import argparse
import json
import os import os
import uuid import sys
import webbrowser from dataclasses import dataclass, replace
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from .apa_browser_adapter import ApaBrowserAdapter
from .callback_client import StateCenterClient from .callback_client import StateCenterClient
from .contract_draft import ContractDraft, load_contract_draft from .contract_draft import (
ContractDraft,
contract_draft_from_dict,
is_placeholder_customer_code,
load_contract_draft,
)
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
from .pdf_utils import ensure_merged_pdf from .pdf_utils import ensure_merged_pdf
from .runtime.errors import LoginFailed as RuntimeLoginFailed from .runtime.errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from .runtime.errors import NodeError
from .runtime.errors import PdfMergeUnsupported as RuntimePdfMergeUnsupported
from .runtime.errors import SupplierContractDisabled as RuntimeSupplierContractDisabled
from .runtime.shell import execute_node from .runtime.shell import execute_node
from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter
NODE_CODE = "CONTRACT_CREATE" NODE_CODE = "CONTRACT_CREATE"
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
class ContractCreateError(NodeError): class ContractCreateError(NodeError):
pass """入参缺失 / 页面元素找不到 / 页面改版,均映射 UI_ELEMENT_NOT_FOUND。"""
def __init__(self, message: str = "") -> None:
# Studio 编译出来的旧 S01 在 input_arg 为空时会直接 raise。
# 这里拦下来改走默认联调 JSON,避免每次改 Main.py 都被覆盖。
if _should_bootstrap_default_input(message):
_bootstrap_default_input_and_exit()
super().__init__(message)
class LoginFailed(RuntimeLoginFailed, ContractCreateError): def default_contract_input_path() -> str:
pass return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
)
class SupplierContractDisabled(RuntimeSupplierContractDisabled, ContractCreateError): def _should_bootstrap_default_input(message: str) -> bool:
pass if "缺少业务入参" not in str(message):
return False
script = os.path.basename(str(sys.argv[0] if sys.argv else "")).lower()
return script == "main.py"
class PdfMergeUnsupported(RuntimePdfMergeUnsupported, ContractCreateError): def _bootstrap_default_input_and_exit() -> None:
pass path = default_contract_input_path()
if not Path(path).is_file():
return
print("未传入 -i/--input,使用默认联调入参:", path, flush=True)
codes_dir = Path(__file__).resolve().parent / "studio" / "codes"
if str(codes_dir) not in sys.path:
sys.path.insert(0, str(codes_dir))
from run_contract import main
raise SystemExit(main())
@dataclass(frozen=True) @dataclass(frozen=True)
@ -55,9 +74,12 @@ class ContractCreateInput:
contract_url: str = CONTRACT_URL contract_url: str = CONTRACT_URL
username: str = "test002" username: str = "test002"
password: str = "1" password: str = "1"
open_browser: bool = False submit_mode: str = "submit"
order_no_list: tuple[str, ...] = ()
template_name: str = ""
draft_path: str | None = None draft_path: str | None = None
merged_pdf_output: str | None = None merged_pdf_output: str | None = None
flow_context: ContractFlowContext | None = None
ContractRunner = Callable[[ContractCreateInput], str] ContractRunner = Callable[[ContractCreateInput], str]
@ -71,13 +93,24 @@ def execute_contract_create(
) -> dict[str, Any]: ) -> dict[str, Any]:
runner = contract_runner or create_contract_in_contract_system runner = contract_runner or create_contract_in_contract_system
# dry_run:只跑到提交前,不回调状态中心(不 start/complete/fail)
if spec.submit_mode == "dry_run":
runner(spec)
return {"status": "dry_run", "submitted": False}
def _run() -> dict[str, Any]: def _run() -> dict[str, Any]:
contract_no = runner(spec) try:
payload = build_complete_payload(spec, contract_no) contract_no = runner(spec)
except NodeError:
raise
except Exception as exc:
raise UnknownRpaError(str(exc)) from exc
if not contract_no:
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
return { return {
"businessRefs": payload["businessRefs"], "businessRefs": {"contractSystemNo": contract_no},
"summary": payload["summary"], "summary": _build_summary(spec),
"artifacts": payload["artifacts"], "artifacts": [],
} }
return execute_node( return execute_node(
@ -91,34 +124,46 @@ def execute_contract_create(
def create_contract_in_contract_system(spec: ContractCreateInput) -> str: 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: if spec.contract_system_no:
return spec.contract_system_no return spec.contract_system_no
if not spec.draft_path:
raise ContractCreateError("draft_path is required for real contract drafting") ctx = spec.flow_context
draft = load_contract_draft(spec.draft_path) if ctx is None:
if not spec.draft_path:
raise ContractCreateError("缺少 flow_context 或 draft_path,无法起草合同")
ctx = build_flow_context(spec, load_contract_draft(spec.draft_path))
browser = ApaBrowserAdapter(url=ctx.contract_url, flow="CONTRACT_CREATE")
return run_contract_create_flow(browser, ctx)
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
draft = contract_draft_from_dict(data)
_validate_data(data, draft)
spec = ContractCreateInput(
job_id=_require(data, "jobId"),
supplier_name=draft.supplier_name or _require(data, "supplierName"),
order_date=draft.order_date or _require(data, "orderDate"),
source_type=draft.source_type or _require(data, "sourceType"),
contract_system_no=_opt(data.get("contractSystemNo")),
attempt_no=int(data.get("attemptNo", 1) or 1),
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "test002"),
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
submit_mode=draft.submit_mode,
order_no_list=tuple(draft.order_no_list),
template_name=draft.template_name,
merged_pdf_output=_opt(data.get("mergedPdfOutput")) or os.getenv("MERGED_PDF_OUTPUT"),
)
ctx = build_flow_context(spec, draft) ctx = build_flow_context(spec, draft)
browser = YiSaiQiBrowserAdapter() return replace(spec, flow_context=ctx)
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: def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
merged_pdf_path = draft.merged_pdf_path merged_pdf_path = _resolve_merged_pdf(draft, spec)
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( return ContractFlowContext(
contract_url=spec.contract_url, contract_url=spec.contract_url,
username=spec.username, username=spec.username,
@ -135,7 +180,9 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
contract_background=draft.contract_background, contract_background=draft.contract_background,
contract_nature=draft.contract_nature, contract_nature=draft.contract_nature,
amount_currency=draft.amount_currency, amount_currency=draft.amount_currency,
amount_type=draft.amount_type,
amount=draft.amount, amount=draft.amount,
contract_title=draft.contract_title,
contract_end_date=draft.contract_end_date, contract_end_date=draft.contract_end_date,
is_major_contract=draft.is_major_contract, is_major_contract=draft.is_major_contract,
is_prepayment=draft.is_prepayment, is_prepayment=draft.is_prepayment,
@ -147,51 +194,158 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
submit_mode=draft.submit_mode, submit_mode=draft.submit_mode,
goods_lines=draft.goods_lines, goods_lines=draft.goods_lines,
merged_pdf_path=merged_pdf_path, merged_pdf_path=merged_pdf_path,
contract_document_path=draft.contract_document_path,
order_no_list=draft.order_no_list,
) )
def build_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[str, Any]: def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
if draft.source_type != "YYC":
return None
if spec.submit_mode == "dry_run":
# dry_run 只跑到提交前,不校验也不上传 PDF
return None
paths = [str(p).strip() for p in draft.platform_pdf_paths if str(p).strip()]
if not paths:
if draft.contract_document_path:
return None
raise PdfMergeUnsupported("YYC 缺少平台合同 PDF(platformPdfPaths 为空)")
for path in paths:
if not Path(path).exists():
raise PdfMergeUnsupported(f"平台合同 PDF 不存在:{path}")
if len(paths) == 1:
return paths[0]
output_path = spec.merged_pdf_output or str(
Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf"
)
try:
return ensure_merged_pdf(paths, output_path)
except Exception as exc:
raise PdfMergeUnsupported(f"YYC 平台合同 PDF 合并失败:{exc}") from exc
def _build_summary(spec: ContractCreateInput) -> dict[str, Any]:
return { return {
"attemptNo": spec.attempt_no, "supplierName": spec.supplier_name,
"executorRunId": f"YSQ-CONTRACT-{uuid.uuid4().hex[:12]}", "contractDate": spec.order_date,
"idempotencyKey": f"{spec.job_id}:{NODE_CODE}:{spec.attempt_no}:{contract_no}", "sourceType": spec.source_type,
"businessRefs": {"contractSystemNo": contract_no}, "orderNoList": list(spec.order_no_list),
"summary": { "templateName": spec.template_name,
"supplierName": spec.supplier_name, "pdfMerged": spec.source_type == "YYC",
"contractDate": spec.order_date, "submitted": True,
"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]: def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
return { missing = []
"attemptNo": spec.attempt_no, for key in ("jobId", "supplierName", "orderDate", "sourceType", "customerCode"):
"errorCode": exc.error_code, if not _str(data.get(key)):
"errorSummary": str(exc), missing.append(key)
"retryable": exc.retryable,
"routeRole": exc.route_role, customer_code = draft.customer_code or _str(data.get("customerCode"))
"artifacts": [], if customer_code and is_placeholder_customer_code(customer_code):
} raise ContractCreateError(
f"customerCode「{customer_code}」是占位符,合同系统客商选择窗搜不到。"
"请换成测试环境真实存在的客商操作码,例如 DW0100046361(重庆亿昊药业有限公司)"
)
if draft.submit_mode not in ("dry_run", "submit"):
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
if not _str(draft.amount):
missing.append("amount")
else:
try:
float(draft.amount)
except (TypeError, ValueError):
raise ContractCreateError(f"amount 不是合法数字:{draft.amount}")
if not draft.goods_lines:
raise ContractCreateError("goodsLines 为空,至少需要一行货品明细")
for index, line in enumerate(draft.goods_lines, start=1):
if not _str(line.goods_code) or not _str(line.goods_name) or line.quantity <= 0:
raise ContractCreateError(
f"goodsLines[{index}] 缺少 goodsCode/goodsName,或 quantity<=0"
)
if missing:
raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
def _require(data: dict[str, Any], key: str) -> str:
value = _str(data.get(key))
if not value:
raise ContractCreateError(f"缺少必填字段:{key}")
return value
def _opt(value: Any) -> str | None:
value = _str(value)
return value or None
def _str(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _clean(value: Any) -> str:
value = _str(value)
if len(value) >= 2 and value[0] == "`" and value[-1] == "`":
value = value[1:-1].strip()
return value
def load_input_data(raw: str) -> dict[str, Any]:
"""解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。"""
raw = (raw or "").strip()
if not raw:
fallback = default_contract_input_path()
if Path(fallback).is_file():
print("未传入 -i/--input,使用默认联调入参:", fallback, flush=True)
raw = fallback
else:
return {}
try:
obj = json.loads(raw)
if isinstance(obj, dict):
return obj
except Exception:
pass
if os.path.isfile(raw):
with open(raw, "r", encoding="utf-8") as file:
return json.load(file)
raise ContractCreateError(f"无法解析入参:既不是合法 JSON,也不是存在的文件路径:{raw}")
def run_contract_create(
data: dict[str, Any],
*,
state_center_url: str,
agent_token: str,
) -> dict[str, Any]:
spec = build_spec_from_data(data)
client = StateCenterClient(state_center_url, agent_token)
return execute_contract_create(spec, client)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE callback skeleton.") parser = argparse.ArgumentParser(description="Run YiSaiQi CONTRACT_CREATE node.")
parser.add_argument("--state-center-url", default=os.getenv("STATE_CENTER_URL", "http://127.0.0.1:8000")) 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("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
parser.add_argument("--job-id", required=True) parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
parser.add_argument("--supplier-name", required=True) parser.add_argument("--job-id")
parser.add_argument("--order-date", required=True) parser.add_argument("--supplier-name")
parser.add_argument("--order-date")
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC") 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("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
parser.add_argument("--attempt-no", type=int, default=1) parser.add_argument("--attempt-no", type=int, default=1)
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL)) 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("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1")) 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("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
return parser.parse_args() return parser.parse_args()
@ -199,23 +353,29 @@ def parse_args() -> argparse.Namespace:
def main() -> int: def main() -> int:
args = parse_args() 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) client = StateCenterClient(args.state_center_url, args.token)
if args.input_json:
spec = build_spec_from_data(load_input_data(args.input_json))
else:
if not (args.job_id and args.supplier_name and args.order_date):
raise SystemExit("缺少 --job-id/--supplier-name/--order-date 或 --input-json")
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,
draft_path=args.draft_path,
merged_pdf_output=args.merged_pdf_output,
)
result = execute_contract_create(spec, client) result = execute_contract_create(spec, client)
print(result) print(json.dumps(result, ensure_ascii=False))
return 0 return 0

View File

@ -8,6 +8,14 @@ from typing import Any
from .contract_flow_steps import ContractGoodsLine from .contract_flow_steps import ContractGoodsLine
_PLACEHOLDER_CUSTOMER_PREFIXES = ("CUST-", "TEST-", "MOCK-")
def is_placeholder_customer_code(code: str) -> bool:
"""CUST-001 / TEST-xxx 这类占位符在合同系统里搜不到,不能拿去点真页面。"""
value = (code or "").strip().upper()
return value.startswith(_PLACEHOLDER_CUSTOMER_PREFIXES)
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractDraft: class ContractDraft:
@ -23,7 +31,9 @@ class ContractDraft:
contract_background: str = "" contract_background: str = ""
contract_nature: str = "主合同" contract_nature: str = "主合同"
amount_currency: str = "人民币" amount_currency: str = "人民币"
amount_type: str = "合同标的金额"
amount: str = "" amount: str = ""
contract_title: str = ""
contract_end_date: str = "" contract_end_date: str = ""
is_major_contract: str = "否" is_major_contract: str = "否"
is_prepayment: str = "否" is_prepayment: str = "否"
@ -36,6 +46,8 @@ class ContractDraft:
goods_lines: list[ContractGoodsLine] = field(default_factory=list) goods_lines: list[ContractGoodsLine] = field(default_factory=list)
platform_pdf_paths: list[str] = field(default_factory=list) platform_pdf_paths: list[str] = field(default_factory=list)
merged_pdf_path: str | None = None merged_pdf_path: str | None = None
contract_document_path: str = ""
order_no_list: list[str] = field(default_factory=list)
def load_contract_draft(path: str | Path) -> ContractDraft: def load_contract_draft(path: str | Path) -> ContractDraft:
@ -52,7 +64,9 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
approval_no=str(item.get("approvalNo", "")), approval_no=str(item.get("approvalNo", "")),
spec=str(item.get("spec", "")), spec=str(item.get("spec", "")),
unit=str(item.get("unit", "")), unit=str(item.get("unit", "")),
quantity=float(item.get("quantity", 0)), quantity=float(item.get("quantity", 0) or 0),
mdm_code=str(item.get("mdmCode", "")),
unit_price=str(item.get("unitPrice", "")),
) )
for item in data.get("goodsLines", []) for item in data.get("goodsLines", [])
] ]
@ -69,7 +83,9 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
contract_background=str(data.get("contractBackground", "")), contract_background=str(data.get("contractBackground", "")),
contract_nature=str(data.get("contractNature", "主合同")), contract_nature=str(data.get("contractNature", "主合同")),
amount_currency=str(data.get("amountCurrency", "人民币")), amount_currency=str(data.get("amountCurrency", "人民币")),
amount_type=str(data.get("amountType", "合同标的金额")),
amount=str(data.get("amount", "")), amount=str(data.get("amount", "")),
contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""),
contract_end_date=str(data.get("contractEndDate", "")), contract_end_date=str(data.get("contractEndDate", "")),
is_major_contract=str(data.get("isMajorContract", "否")), is_major_contract=str(data.get("isMajorContract", "否")),
is_prepayment=str(data.get("isPrepayment", "否")), is_prepayment=str(data.get("isPrepayment", "否")),
@ -82,4 +98,8 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines=goods_lines, goods_lines=goods_lines,
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])], platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
merged_pdf_path=data.get("mergedPdfPath"), merged_pdf_path=data.get("mergedPdfPath"),
contract_document_path=str(
data.get("contractDocumentPath") or data.get("contractBodyPath") or ""
),
order_no_list=[str(x) for x in data.get("orderNoList", [])],
) )

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"jobId": "YYC-重庆亿昊药业-2fa0-20260812-16abff",
"supplierName": "重庆亿昊药业有限公司",
"orderDate": "2026-08-12",
"sourceType": "YYC",
"templateType": "NON_FORMAT",
"templateName": "非格式--采购协议",
"customerCode": "DW0100046361",
"customerName": "重庆亿昊药业有限公司",
"contractCategory": "其他协议(采购)",
"settlementType": "付款",
"contractBackground": "自动化测试合同起草",
"contractNature": "主合同",
"contractTitle": "test",
"amountCurrency": "人民币",
"amountType": "合同标的金额",
"amount": "50000.00",
"contractEndDate": "2026-12-31",
"isMajorContract": "否",
"isPrepayment": "否",
"belongYear": "2026",
"paymentMethod": "电汇",
"paymentDays": "30",
"contractStatus": "正式合同",
"containsBloodProducts": "否",
"submitMode": "dry_run",
"orderNoList": [],
"platformPdfPaths": [],
"goodsLines": [
{
"operationCode": "ADD",
"goodsCode": "FQX0100118047",
"goodsName": "盐酸托烷司琼注射液",
"approvalNo": "苏20160372",
"spec": "5ml:5mg*5支",
"unit": "盒",
"quantity": 10,
"mdmCode": "",
"unitPrice": "50.00"
}
],
"stateCenterUrl": "http://127.0.0.1:8000",
"agentToken": "dev-agent-token"
}

View File

@ -1,11 +1,11 @@
{ {
"supplierName": "小药药供应商", "supplierName": "重庆亿昊药业有限公司",
"orderDate": "2026-08-11", "orderDate": "2026-08-11",
"sourceType": "XYY", "sourceType": "XYY",
"templateType": "FORMAT", "templateType": "FORMAT",
"templateName": "购销协议书(2025)", "templateName": "购销协议书(2025)",
"customerCode": "CUST-XYY-001", "customerCode": "DW0100046361",
"customerName": "小药药供应商", "customerName": "重庆亿昊药业有限公司",
"contractCategory": "采购协议", "contractCategory": "采购协议",
"settlementType": "付款", "settlementType": "付款",
"contractBackground": "按小药药订单自动起草采购协议", "contractBackground": "按小药药订单自动起草采购协议",

View File

@ -1,11 +1,11 @@
{ {
"supplierName": "重庆亿昊药业", "supplierName": "重庆亿昊药业有限公司",
"orderDate": "2026-08-11", "orderDate": "2026-08-11",
"sourceType": "YYC", "sourceType": "YYC",
"templateType": "NON_FORMAT", "templateType": "NON_FORMAT",
"templateName": "非格式--采购协议", "templateName": "非格式--采购协议",
"customerCode": "CUST-YYC-001", "customerCode": "DW0100046361",
"customerName": "重庆亿昊药业", "customerName": "重庆亿昊药业有限公司",
"contractCategory": "采购协议", "contractCategory": "采购协议",
"settlementType": "付款", "settlementType": "付款",
"contractBackground": "按医药城订单自动起草采购协议", "contractBackground": "按医药城订单自动起草采购协议",

View File

@ -1 +1,4 @@
pypdf==5.1.0 pypdf==5.1.0
selenium>=4.15.0
webdriver-manager>=4.0.0
requests>=2.31.0

View File

@ -0,0 +1,153 @@
"""Shared ACCEPT report for every RPA flow (any platform / page).
New flows (ORDER_FETCH, REBATE_*, M5, ...) must use this instead of
reading screenshots. Screenshots stay on disk for humans; this text is
the agent oracle.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
from .errors import NodeError
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
SCREENSHOT_ROOT = PACKAGE_ROOT / "screenshots"
class HasScreenshot(Protocol):
def screenshot(self, name: str) -> str: ...
@dataclass
class AcceptLine:
step: str
title: str
ok: bool
facts: dict[str, str]
mismatches: list[str]
def safe_flow_name(flow: str) -> str:
text = (flow or "default").strip() or "default"
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in text)
def artifact_dir(flow: str) -> Path:
return SCREENSHOT_ROOT / safe_flow_name(flow)
def evidence_shot(browser: HasScreenshot, name: str, *, failed: bool = False) -> str:
"""Save a screenshot only on failure. Set RPA_SCREENSHOT=always to keep every frame."""
mode = (os.environ.get("RPA_SCREENSHOT") or "fail").strip().lower()
if not failed and mode != "always":
return ""
return browser.screenshot(name)
def accept_log_path(flow: str = "default") -> Path:
override = os.environ.get("RPA_ACCEPT_LOG")
if override:
return Path(override)
return artifact_dir(flow) / "ACCEPT.txt"
def field_match(actual: str, expected: str) -> bool:
actual_text = (actual or "").strip()
expected_text = (expected or "").strip()
if not expected_text:
return True
if actual_text == expected_text:
return True
compact_a = actual_text.replace(" ", "").replace(",", "")
compact_e = expected_text.replace(" ", "").replace(",", "")
if compact_a == compact_e or compact_a.replace("-", "") == compact_e.replace("-", ""):
return True
try:
return float(compact_a) == float(compact_e)
except ValueError:
pass
yes = {"是", "1", "Y", "YES", "TRUE"}
no = {"否", "2", "0", "N", "NO", "FALSE"}
if compact_a.upper() in yes and compact_e.upper() in yes:
return True
if compact_a.upper() in no and compact_e.upper() in no:
return True
return compact_e in compact_a or compact_a in compact_e
def format_accept(line: AcceptLine) -> str:
status = "ok" if line.ok else "FAIL"
facts = " ".join(f"{key}={value}" for key, value in line.facts.items())
extra = f" {' | '.join(line.mismatches)}" if line.mismatches else ""
return f"ACCEPT {line.step} {status} {line.title} {facts}{extra}".rstrip()
@dataclass
class AcceptanceReport:
"""DOM / page-state checklist. One instance per flow run."""
flow: str = "default"
lines: list[AcceptLine] = field(default_factory=list)
def reset(self) -> None:
self.lines = []
def check(
self,
step: str,
title: str,
pairs: list[tuple[str, object, object]],
*,
browser: HasScreenshot | None = None,
screenshot_on_fail: str = "",
) -> None:
facts: dict[str, str] = {}
mismatches: list[str] = []
for name, actual, expected in pairs:
actual_text = "" if actual is None else str(actual).strip()
expected_text = "" if expected is None else str(expected).strip()
facts[name] = actual_text or "(empty)"
if expected_text and not field_match(actual_text, expected_text):
mismatches.append(f"{name} expected={expected_text!r} actual={actual_text!r}")
ok = not mismatches
self.lines.append(AcceptLine(step, title, ok, facts, mismatches))
print(format_accept(self.lines[-1]), flush=True)
self.dump()
if ok:
return
if browser is not None and screenshot_on_fail:
evidence_shot(browser, screenshot_on_fail, failed=True)
raise NodeError(f"ACCEPT {step} FAIL " + "; ".join(mismatches))
def note(
self,
step: str,
title: str,
facts: dict[str, object],
*,
ok: bool = True,
raise_error: bool = True,
) -> None:
rendered = {key: ("" if value is None else str(value).strip()) or "(empty)" for key, value in facts.items()}
self.lines.append(AcceptLine(step, title, ok, rendered, [] if ok else ["failed"]))
print(format_accept(self.lines[-1]), flush=True)
self.dump()
if not ok and raise_error:
raise NodeError(f"ACCEPT {step} FAIL {title} " + " ".join(f"{k}={v}" for k, v in rendered.items()))
def dump(self) -> None:
path = accept_log_path(self.flow)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self.summary_text(), encoding="utf-8")
except OSError as exc:
print(f"ACCEPT log write skipped: {exc}", flush=True)
def summary_text(self) -> str:
passed = sum(1 for line in self.lines if line.ok)
rows = [f"ACCEPT SUMMARY {self.flow} {passed}/{len(self.lines)} ok"]
rows.extend(format_accept(line) for line in self.lines)
return "\n".join(rows) + "\n"

View File

@ -23,3 +23,9 @@ class PdfMergeUnsupported(NodeError):
error_code = "CAP_PDF_MERGE_UNSUPPORTED" error_code = "CAP_PDF_MERGE_UNSUPPORTED"
retryable = False retryable = False
route_role = "OPS" route_role = "OPS"
class UnknownRpaError(NodeError):
error_code = "RPA_UNKNOWN_ERROR"
retryable = True
route_role = "OPS"

View File

@ -1,14 +1,13 @@
"""Generic start → runner → complete/fail shell.""" """Generic start → runner → complete/fail shell."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from typing import Any, Callable, Dict, Optional
from typing import Any
from ..callback_client import StateCenterClient from ..callback_client import StateCenterClient
from .errors import NodeError from .errors import NodeError
from .payloads import build_complete_payload, build_fail_payload from .payloads import build_complete_payload, build_fail_payload
Runner = Callable[[], dict[str, Any] | None] Runner = Callable[[], Optional[Dict[str, Any]]]
def execute_node( def execute_node(

View File

@ -1,116 +1,47 @@
{ {
"knownLoginSelectors": { "login": {
"username": "input[name='j_username']", "username": "input[name='j_username']",
"password": "input[name='j_password']", "password": "input[name='j_password']",
"submit": "input[name='btn_submit']" "submit": "input[name='btn_submit']",
"submitLink": "text=登录"
}, },
"candidateSelectors": { "navigation": {
"contractManagementMenu": [ "draftPage": "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/draft/draft.jsp?isSimpleCategory=false&mainModelName=com.landray.kmss.km.agreement.model.KmAgreementApply&modelName=com.landray.kmss.km.agreement.model.KmAgreementTmpl",
"text=合同管理", "templatePrefix": "非格式-采购协议"
"aria=合同管理" },
], "form": {
"contractDraftMenu": [ "customerCode": "input[name='extendDataFormInfo.value(fd_customeropcode)']",
"text=合同起草", "customerName": "input[name='extendDataFormInfo.value(fd_customername)']",
"aria=合同起草" "contractCategory": "xform:extendDataFormInfo.value(fd_contract_cate_type)",
], "coverTemplate": "xform:extendDataFormInfo.value(fd_contract_cover_type)",
"recentNonFormatPurchaseTemplate": [ "settlementType": "select#fdSettype",
"text=非格式--采购协议", "contractBackground": "textarea[name='extendDataFormInfo.value(fd_desc_info)']",
"text=非格式--采购协议...", "contractNatureMain": "input[name='fdType'][value='00']",
"title=非格式--采购协议" "contractNatureSub": "input[name='fdType'][value='01']",
], "docSubject": "input[name='docSubject']",
"customerCode": [ "amountType": "xform:extendDataFormInfo.value(fd_all_amount_type)",
"label=客户编码", "amount": "input[name='fdAccount']",
"input[name='fdCustomerCode']", "signDate": "input[name='fdSigndate']",
"input[placeholder*='客户编码']" "beginDate": "input[name='fdEffedate']",
], "endDate": "input[name='fdEnddate']",
"customerName": [ "isMajorContract": "input[name='extendDataFormInfo.value(fd_is_major_contract)']",
"label=客户名称", "isPrepayment": "input[name='extendDataFormInfo.value(fd_advance_amout)']",
"input[name='fdCustomerName']", "belongYear": "input[name='extendDataFormInfo.value(fd_contract_year_text)']",
"input[placeholder*='客户名称']" "paymentMethod": "input[name='extendDataFormInfo.value(fd_paytype)']",
], "paymentDays": "input[name='extendDataFormInfo.value(fd_pay_time_limit)']",
"contractVersionType": [ "contractStatus": "input[name='extendDataFormInfo.value(fd_suconstatus)']",
"label=合同版本类型", "containsBloodProducts": "input[name='extendDataFormInfo.value(fd_3d1ce3953f1162)']",
"select[name='fdTemplateType']" "selectGoodsButton": "div[title='选择商品']",
], "goodsDialogFrame": "iframe[src*='EopBasedataMaterial']",
"contractCategory": [ "goodsOperationCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
"label=合同类别", "goodsCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_opcode)']",
"select[name='fdContractCategory']" "goodsName": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsname)']",
], "goodsMdmCode": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_mdmCode)']",
"settlementType": [ "goodsApprovalNo": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_registdocno)']",
"label=结算类型", "goodsSpec": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodstype)']",
"select[name='fdSettlementType']" "goodsUnit": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsunit)']",
], "goodsQuantity": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)']",
"contractBackground": [ "goodsUnitPrice": "input[name='extendDataFormInfo.value(fd_detail1.0.fd_unitprice)']",
"label=合同背景概述", "submit": "text=提交"
"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=合同编号"
]
} }
} }

790
rpa_yisaiqi/studio/Main.seq Normal file
View File

@ -0,0 +1,790 @@
{
"LineList": [
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143010000000/EPin_Flow_In",
"Px": "697",
"Py": "314.931"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608141000000001/EPin_Flow_Out",
"Px": "547",
"Py": "296"
},
"LineTag": "202608149010000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143020000000/EPin_Flow_In",
"Px": "977",
"Py": "314.931"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143010000000/EPin_Flow_Out",
"Px": "827",
"Py": "314.931"
},
"LineTag": "202608149020000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143030000000/EPin_Flow_In",
"Px": "1257",
"Py": "320"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143020000000/EPin_Flow_Out",
"Px": "1107",
"Py": "314.931"
},
"LineTag": "202608149030000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143040000000/EPin_Flow_In",
"Px": "417",
"Py": "520"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143030000000/EPin_Flow_Out",
"Px": "1387",
"Py": "320"
},
"LineTag": "202608149040000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143050000000/EPin_Flow_In",
"Px": "697",
"Py": "520"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143040000000/EPin_Flow_Out",
"Px": "547",
"Py": "520"
},
"LineTag": "202608149050000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143060000000/EPin_Flow_In",
"Px": "417",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143050000000/EPin_Flow_Out",
"Px": "827",
"Py": "520"
},
"LineTag": "202608149060000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143070000000/EPin_Flow_In",
"Px": "697",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143060000000/EPin_Flow_Out",
"Px": "547",
"Py": "720"
},
"LineTag": "202608149070000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143080000000/EPin_Flow_In",
"Px": "977",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143070000000/EPin_Flow_Out",
"Px": "827",
"Py": "720"
},
"LineTag": "202608149080000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143090000000/EPin_Flow_In",
"Px": "1257",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143080000000/EPin_Flow_Out",
"Px": "1107",
"Py": "720"
},
"LineTag": "202608149090000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143100000000/EPin_Flow_In",
"Px": "1537",
"Py": "720"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143090000000/EPin_Flow_Out",
"Px": "1387",
"Py": "720"
},
"LineTag": "202608149100000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143110000000/EPin_Flow_In",
"Px": "417",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143100000000/EPin_Flow_Out",
"Px": "1667",
"Py": "720"
},
"LineTag": "202608149110000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143120000000/EPin_Flow_In",
"Px": "697",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143110000000/EPin_Flow_Out",
"Px": "547",
"Py": "920"
},
"LineTag": "202608149120000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143130000000/EPin_Flow_In",
"Px": "977",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143120000000/EPin_Flow_Out",
"Px": "827",
"Py": "920"
},
"LineTag": "202608149130000000",
"LineType": 0
},
{
"LineColor": "255, 255, 255",
"LineCommented": 0,
"LineEnd": {
"Pidx": "202608143140000000/EPin_Flow_In",
"Px": "1257",
"Py": "920"
},
"LineNote": "",
"LineSplit": {
"Px": "",
"Py": ""
},
"LineStart": {
"Pidx": "202608143130000000/EPin_Flow_Out",
"Px": "1107",
"Py": "920"
},
"LineTag": "202608149140000000",
"LineType": 0
}
],
"NodeList": [
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": "",
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "280",
"NodeSubType": "EnumWidget_Begin",
"NodeTag": "202608141000000001",
"NodeTitle": "开始"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PBCode\": \"cHJpbnQoIltTMDEg5Yid5aeL5YyW5bm25Zue6LCDIHN0YXJ0XSIpCmltcG9ydCBzeXMsIG9zLCBqc29uCmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKX1JFUE9fUk9PVCA9IG5leHQoCiAgICBwIGZvciBwIGluIFBhdGgoX19maWxlX18pLnJlc29sdmUoKS5wYXJlbnRzCiAgICBpZiAocCAvICJycGFfeWlzYWlxaSIgLyAiX19pbml0X18ucHkiKS5leGlzdHMoKQopCmlmIHN0cihfUkVQT19ST09UKSBub3QgaW4gc3lzLnBhdGg6CiAgICBzeXMucGF0aC5pbnNlcnQoMCwgc3RyKF9SRVBPX1JPT1QpKQoKaW1wb3J0IHJwYV95aXNhaXFpLl9mbG93X3N0YXRlIGFzIF9zdApmcm9tIHJwYV95aXNhaXFpLmNvbnRyYWN0X2NyZWF0ZV9ub2RlIGltcG9ydCBDb250cmFjdENyZWF0ZUVycm9yLCBsb2FkX2lucHV0X2RhdGEsIGJ1aWxkX3NwZWNfZnJvbV9kYXRhCmZyb20gcnBhX3lpc2FpcWkuY2FsbGJhY2tfY2xpZW50IGltcG9ydCBTdGF0ZUNlbnRlckNsaWVudApmcm9tIHJwYV95aXNhaXFpLmFwYV9icm93c2VyX2FkYXB0ZXIgaW1wb3J0IEFwYUJyb3dzZXJBZGFwdGVyCgoKZGVmIF9maXJzdCgqdmFsdWVzKToKICAgIGZvciB2YWx1ZSBpbiB2YWx1ZXM6CiAgICAgICAgaWYgdmFsdWUgaXMgbm90IE5vbmUgYW5kIHN0cih2YWx1ZSkuc3RyaXAoKToKICAgICAgICAgICAgcmV0dXJuIHN0cih2YWx1ZSkuc3RyaXAoKQogICAgcmV0dXJuICIiCgoKcmF3ID0gKHNlbGYuaW5wdXRfYXJnIG9yICIiKS5zdHJpcCgpCmlmIG5vdCByYXc6CiAgICByYXcgPSBvcy5nZXRlbnYoIkNPTlRSQUNUX0lOUFVUX0pTT04iLCAiIikuc3RyaXAoKQppZiBub3QgcmF3OgogICAgcmF3ID0gc3RyKF9SRVBPX1JPT1QgLyAicnBhX3lpc2FpcWkiIC8gImV4YW1wbGVzIiAvICJjb250cmFjdF9jcmVhdGVfaW5wdXRfZHJ5X3J1bi5qc29uIikKICAgIHByaW50KCLmnKrkvKDlhaUgLWkvLS1pbnB1dO+8jOS9v+eUqOm7mOiupOiBlOiwg+WFpeWPgu+8miIsIHJhdykKaWYgbm90IHJhdzoKICAgIHJhaXNlIENvbnRyYWN0Q3JlYXRlRXJyb3IoIue8uuWwkeS4muWKoeWFpeWPgu+8muivt+mAmui/hyAtaS8tLWlucHV0IOS8oOWFpSBKU09OIOWtl+espuS4suaIluaWh+S7tui3r+W+hCIpCgpkYXRhID0gbG9hZF9pbnB1dF9kYXRhKHJhdykKc3RhdGVfY2VudGVyX3VybCA9IF9maXJzdCgKICAgIGRhdGEuZ2V0KCJzdGF0ZUNlbnRlclVybCIpLCBkYXRhLmdldCgic3RhdGVDZW50ZXJVUkwiKSwKICAgIG9zLmdldGVudigiU1RBVEVfQ0VOVEVSX1VSTCIpLCAiaHR0cDovLzEyNy4wLjAuMTo4MDAwIiwKKQphZ2VudF90b2tlbiA9IF9maXJzdChkYXRhLmdldCgiYWdlbnRUb2tlbiIpLCBvcy5nZXRlbnYoIkFHRU5UX1RPS0VOIiksICJkZXYtYWdlbnQtdG9rZW4iKQoKX3N0LnNwZWMgPSBidWlsZF9zcGVjX2Zyb21fZGF0YShkYXRhKQpfc3QuY2xpZW50ID0gU3RhdGVDZW50ZXJDbGllbnQoc3RhdGVfY2VudGVyX3VybCwgYWdlbnRfdG9rZW4pCl9zdC5jdHggPSBfc3Quc3BlYy5mbG93X2NvbnRleHQKX3N0LmJyb3dzZXIgPSBBcGFCcm93c2VyQWRhcHRlcih1cmw9X3N0LmN0eC5jb250cmFjdF91cmwpCl9zdC5pc19kcnlfcnVuID0gKF9zdC5zcGVjLnN1Ym1pdF9tb2RlID09ICJkcnlfcnVuIikKCmlmIG5vdCBfc3QuaXNfZHJ5X3J1biBhbmQgb3MuZ2V0ZW52KCJSUEFfQ0FMTEJBQ0tfU1RBVEVfQ0VOVEVSIikgPT0gIjEiOgogICAgX3N0LmNsaWVudC5zdGFydF9ub2RlKF9zdC5zcGVjLmpvYl9pZCwgIkNPTlRSQUNUX0NSRUFURSIp\",\n \"PImport\": \"\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAxIOWIneWni+WMluW5tuWbnuiwgyBzdGFydA==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143010000000",
"NodeTitle": "插入代码块#1-13"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PBCode\": \"cHJpbnQoIltTMDIg5omT5byA5ZCI5ZCM57O757ufXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBvcGVuX2NvbnRyYWN0X3N5c3RlbQoKb3Blbl9jb250cmFjdF9zeXN0ZW0oX3N0LmJyb3dzZXIsIF9zdC5jdHgp\",\n \"PImport\": \"\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAyIOaJk+W8gOWQiOWQjOezu+e7nw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143020000000",
"NodeTitle": "插入代码块#1-12"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDMg55m75b2V5ZCI5ZCM57O757ufXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBsb2dpbl9jb250cmFjdF9zeXN0ZW0KCmxvZ2luX2NvbnRyYWN0X3N5c3RlbShfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzAzIOeZu+W9leWQiOWQjOezu+e7nw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "280",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143030000000",
"NodeTitle": "插入代码块#1-11"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDQg6L+b5YWl5ZCI5ZCM6LW36I2JXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBlbnRlcl9jb250cmFjdF9jcmVhdGUKCmVudGVyX2NvbnRyYWN0X2NyZWF0ZShfc3QuYnJvd3Nlcik=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA0IOi/m+WFpeWQiOWQjOi1t+iNiQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "480",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143040000000",
"NodeTitle": "插入代码块#1-10"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDUg6YCJ5oup5qih5p2/XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBzZWxlY3RfY29udHJhY3RfdGVtcGxhdGUKCnNlbGVjdF9jb250cmFjdF90ZW1wbGF0ZShfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA1IOmAieaLqeaooeadvw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "480",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143050000000",
"NodeTitle": "插入代码块#1-9"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDYg5aGr5YaZ562+57qm5pa5XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X3NpZ25hdG9yeV9pbmZvCgpmaWxsX2NvbnRyYWN0X3NpZ25hdG9yeV9pbmZvKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA2IOWhq+WGmeetvue6puaWuQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143060000000",
"NodeTitle": "插入代码块#1-8"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDcg5aGr5YaZ5Z+656GA5L+h5oGvXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2Jhc2ljX2luZm8KCmZpbGxfY29udHJhY3RfYmFzaWNfaW5mbyhfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA3IOWhq+WGmeWfuuehgOS/oeaBrw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143070000000",
"NodeTitle": "插入代码块#1-7"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDgg5aGr5YaZ6YeR6aKd5pel5pyfXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2Ftb3VudF9hbmRfZGF0ZXMKCmZpbGxfY29udHJhY3RfYW1vdW50X2FuZF9kYXRlcyhfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA4IOWhq+WGmemHkemineaXpeacnw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143080000000",
"NodeTitle": "插入代码块#1-6"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMDkg5aGr5YaZ6LSn5ZOB5piO57uGXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X2dvb2RzX2xpbmVzCgpmaWxsX2NvbnRyYWN0X2dvb2RzX2xpbmVzKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzA5IOWhq+WGmei0p+WTgeaYjue7hg==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143090000000",
"NodeTitle": "插入代码块#1-5"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTAg5aGr5YaZ5LuY5qy+5L+h5oGvXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBmaWxsX2NvbnRyYWN0X3BheW1lbnRfaW5mbwoKZmlsbF9jb250cmFjdF9wYXltZW50X2luZm8oX3N0LmJyb3dzZXIsIF9zdC5jdHgp\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEwIOWhq+WGmeS7mOasvuS/oeaBrw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1540",
"NodePy": "680",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143100000000",
"NodeTitle": "插入代码块#1-4"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTEg5LiK5Lyg5ZCI5ZCMUERGXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCB1cGxvYWRfY29udHJhY3RfZG9jdW1lbnRfaWZfbmVlZGVkCgp1cGxvYWRfY29udHJhY3RfZG9jdW1lbnRfaWZfbmVlZGVkKF9zdC5icm93c2VyLCBfc3QuY3R4KQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzExIOS4iuS8oOWQiOWQjFBERg==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "420",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143110000000",
"NodeTitle": "插入代码块#1-3"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTIg5o+Q5Lqk6YCB5a6hXSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCBzdWJtaXRfY29udHJhY3RfZm9yX2FwcHJvdmFsCgpfc3Quc3VibWl0dGVkID0gc3VibWl0X2NvbnRyYWN0X2Zvcl9hcHByb3ZhbChfc3QuYnJvd3NlciwgX3N0LmN0eCk=\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEyIOaPkOS6pOmAgeWuoQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "700",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143120000000",
"NodeTitle": "插入代码块#1-2"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTMg6K+75Y+W5ZCI5ZCM57yW5Y+3XSIpCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKZnJvbSBycGFfeWlzYWlxaS5jb250cmFjdF9mbG93X3N0ZXBzIGltcG9ydCByZWFkX2NvbnRyYWN0X251bWJlcgoKaWYgX3N0LnN1Ym1pdHRlZDoKICAgIF9zdC5jb250cmFjdF9ubyA9IHJlYWRfY29udHJhY3RfbnVtYmVyKF9zdC5icm93c2VyKQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzEzIOivu+WPluWQiOWQjOe8luWPtw==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "980",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143130000000",
"NodeTitle": "插入代码块#1-1"
}
},
{
"NodePanelData": {
"ExpandedGroups": [
],
"InputProperty": {
"mouse_x": 0,
"mouse_y": 0
},
"PubProperty": "",
"RetPins": [
]
},
"NodeProperty": {
"EP_CONTROL": {
"EP_CONTROL_IMPORT": "{\n \"PImport\": \"\",\n \"PBCode\": \"cHJpbnQoIltTMTQg5Zue6LCDIGNvbXBsZXRlXSIpCmltcG9ydCBqc29uCmltcG9ydCBycGFfeWlzYWlxaS5fZmxvd19zdGF0ZSBhcyBfc3QKCmlmIG5vdCBfc3QuaXNfZHJ5X3J1biBhbmQgX3N0LmNvbnRyYWN0X25vIGFuZCBvcy5nZXRlbnYoIlJQQV9DQUxMQkFDS19TVEFURV9DRU5URVIiKSA9PSAiMSI6CiAgICBfc3QuY2xpZW50LmNvbXBsZXRlX25vZGUoX3N0LnNwZWMuam9iX2lkLCAiQ09OVFJBQ1RfQ1JFQVRFIiwgewogICAgICAgICJidXNpbmVzc1JlZnMiOiB7ImNvbnRyYWN0U3lzdGVtTm8iOiBfc3QuY29udHJhY3Rfbm99LAogICAgfSkKcHJpbnQoanNvbi5kdW1wcyh7InN0YXR1cyI6ICJkb25lIiwgImNvbnRyYWN0U3lzdGVtTm8iOiBfc3QuY29udHJhY3Rfbm99LCBlbnN1cmVfYXNjaWk9RmFsc2UpKQ==\"\n}\n"
},
"EP_Gen": {
"EP_Gen_AFTERDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_BEFOREDELAY": "{\n \"val\": \"MA==\"\n}\n",
"EP_Gen_NOTE": "{\n \"val\": \"UzE0IOWbnuiwgyBjb21wbGV0ZQ==\"\n}\n"
}
},
"NodeUI": {
"NodeElementID": "",
"NodeModel": "2",
"NodePropErr": false,
"NodePx": "1260",
"NodePy": "880",
"NodeSubType": "EnumWidget_Code_Custom",
"NodeTag": "202608143140000000",
"NodeTitle": "插入代码块#1"
}
}
],
"SeqTag": "Main",
"ViewCenterPx": "1357.02",
"ViewCenterPy": "547.412",
"ViewScaling": "0.564474"
}

View File

@ -0,0 +1,46 @@
{
"GlobalVar": [
],
"OpenFlow": "[\n {\n \"current-tab\": \"true\",\n \"seq\": \"Main\",\n \"tag\": \"2026081210133309518\",\n \"title\": \"Main\",\n \"type\": \"bp\"\n }\n]\n",
"ProInfo": {
"CodeCustomCount": "0",
"ProAbsolutePath": "D:/work/AutoRpa/rpa_yisaiqi/studio",
"ProChange": "2026/08/18 13:12:33",
"ProColorRef": "4",
"ProCreate": "2026/08/12 10:13:31",
"ProDesc": "",
"ProDocVersion": "1.0.0.4",
"ProImports": "",
"ProIsAdminRun": "0",
"ProIsNewReturn": "1",
"ProName": "AutoRpa",
"ProStudio": "2026.1.0.36",
"ProTag": "2026081210133159316",
"ProTheme": "light",
"ProUIADeepMatch": "1",
"ProUserID": "34BFF76918A9449ABAB323149DDCEA73",
"ProUserName": "Administrator",
"ProVersion": "1.0.0.0",
"operatingSystem": 0,
"releaseUserName": "qm01"
},
"SeqGroupList": [
],
"SeqPythonModuleList": [
],
"SeqTextFileList": [
],
"SequenceList": [
{
"sdc": "",
"seq": "Main",
"seqptag": "",
"seqret": "1",
"seqtag": "2026081210133309518",
"spr": [
],
"spv": [
]
}
]
}

View File

@ -0,0 +1,22 @@
# coding=utf-8
# 编译日期:2026-08-12 10:13:31
# 版权所有:www.i-search.com.cn
# 测试运行或调试以上全局函数入口
if __name__ == '__main__':
pass

View File

@ -0,0 +1,200 @@
# coding=utf-8
# 编译日期:2026-08-17 16:28:48
# 版权所有:www.i-search.com.cn
import ubpa.init_input as iinput
from ubpa.base_util import StdOutHook, ExceptionHandler
import time
import pdb
from ubpa.ilog import ILog
import getopt
from sys import argv
import sys
import os
import datetime as is_datetime
import pandas
from ubpa.base_img import *
import ubpa.iplatform as iplatform
class AutoRpa:
def __init__(self,**kwargs):
self.__logger = ILog(__file__)
self.path = set_img_res_path(__file__)
self.robot_no = ''
self.proc_no = ''
self.job_no = ''
self.input_arg = ''
if('robot_no' in kwargs.keys()):
self.robot_no = kwargs['robot_no']
if('proc_no' in kwargs.keys()):
self.proc_no = kwargs['proc_no']
if('job_no' in kwargs.keys()):
self.job_no = kwargs['job_no']
ILog.JOB_NO, ILog.OLD_STDOUT = self.job_no, sys.stdout
sys.stdout = StdOutHook(self.job_no, sys.stdout)
ExceptionHandler.JOB_NO, ExceptionHandler.OLD_STDERR = self.job_no, sys.stderr
sys.excepthook = ExceptionHandler.handle_exception
if('input_arg' in kwargs.keys()):
self.input_arg = kwargs['input_arg']
if(len(self.input_arg) <= 0):
self.input_arg = iinput.load_init(__file__)
if self.input_arg is None:
sys.exit(0)
def Main(self):
#插入代码块#1-13
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143010000000,Title:插入代码块#1-13,Note:S01 初始化并回调 start')
print("[S01 初始化并回调 start]")
import sys, os, json
from pathlib import Path
_REPO_ROOT = next(
p for p in Path(__file__).resolve().parents
if (p / "rpa_yisaiqi" / "__init__.py").exists()
)
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_create_node import ContractCreateError, load_input_data, build_spec_from_data
from rpa_yisaiqi.callback_client import StateCenterClient
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
def _first(*values):
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
raw = (self.input_arg or "").strip()
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw)
if not raw:
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径")
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"), data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"), "http://127.0.0.1:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
_st.spec = build_spec_from_data(data)
_st.client = StateCenterClient(state_center_url, agent_token)
_st.ctx = _st.spec.flow_context
_st.browser = ApaBrowserAdapter(url=_st.ctx.contract_url)
_st.is_dry_run = (_st.spec.submit_mode == "dry_run")
if not _st.is_dry_run and os.getenv("RPA_CALLBACK_STATE_CENTER") == "1":
_st.client.start_node(_st.spec.job_id, "CONTRACT_CREATE")
#插入代码块#1-12
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143020000000,Title:插入代码块#1-12,Note:S02 打开合同系统')
print("[S02 打开合同系统]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import open_contract_system
open_contract_system(_st.browser, _st.ctx)
#插入代码块#1-11
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143030000000,Title:插入代码块#1-11,Note:S03 登录合同系统')
print("[S03 登录合同系统]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import login_contract_system
login_contract_system(_st.browser, _st.ctx)
#插入代码块#1-10
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143040000000,Title:插入代码块#1-10,Note:S04 进入合同起草')
print("[S04 进入合同起草]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import enter_contract_create
enter_contract_create(_st.browser)
#插入代码块#1-9
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143050000000,Title:插入代码块#1-9,Note:S05 选择模板')
print("[S05 选择模板]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import select_contract_template
select_contract_template(_st.browser, _st.ctx)
#插入代码块#1-8
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143060000000,Title:插入代码块#1-8,Note:S06 填写签约方')
print("[S06 填写签约方]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_signatory_info
fill_contract_signatory_info(_st.browser, _st.ctx)
#插入代码块#1-7
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143070000000,Title:插入代码块#1-7,Note:S07 填写基础信息')
print("[S07 填写基础信息]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info
fill_contract_basic_info(_st.browser, _st.ctx)
#插入代码块#1-6
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143080000000,Title:插入代码块#1-6,Note:S08 填写金额日期')
print("[S08 填写金额日期]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_amount_and_dates
fill_contract_amount_and_dates(_st.browser, _st.ctx)
#插入代码块#1-5
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143090000000,Title:插入代码块#1-5,Note:S09 填写货品明细')
print("[S09 填写货品明细]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_goods_lines
fill_contract_goods_lines(_st.browser, _st.ctx)
#插入代码块#1-4
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143100000000,Title:插入代码块#1-4,Note:S10 填写付款信息')
print("[S10 填写付款信息]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import fill_contract_payment_info
fill_contract_payment_info(_st.browser, _st.ctx)
#插入代码块#1-3
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143110000000,Title:插入代码块#1-3,Note:S11 上传合同PDF')
print("[S11 上传合同PDF]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import upload_contract_document_if_needed
upload_contract_document_if_needed(_st.browser, _st.ctx)
#插入代码块#1-2
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143120000000,Title:插入代码块#1-2,Note:S12 提交送审')
print("[S12 提交送审]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import submit_contract_for_approval
_st.submitted = submit_contract_for_approval(_st.browser, _st.ctx)
#插入代码块#1-1
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143130000000,Title:插入代码块#1-1,Note:S13 读取合同编号')
print("[S13 读取合同编号]")
import rpa_yisaiqi._flow_state as _st
from rpa_yisaiqi.contract_flow_steps import read_contract_number
if _st.submitted:
_st.contract_no = read_contract_number(_st.browser)
#插入代码块#1
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:202608143140000000,Title:插入代码块#1,Note:S14 回调 complete')
print("[S14 回调 complete]")
import json
import rpa_yisaiqi._flow_state as _st
if not _st.is_dry_run and _st.contract_no and os.getenv("RPA_CALLBACK_STATE_CENTER") == "1":
_st.client.complete_node(_st.spec.job_id, "CONTRACT_CREATE", {
"businessRefs": {"contractSystemNo": _st.contract_no},
})
print(json.dumps({"status": "done", "contractSystemNo": _st.contract_no}, ensure_ascii=False))
if __name__ == '__main__':
ILog.begin_init()
robot_no = ''
proc_no = ''
job_no = ''
input_arg = ''
params = {}
fn = "Main"
flow_params={'Main':[]}
try:
argv = sys.argv[1:]
opts, args = getopt.getopt(argv,"hr:p:j:i:c:",["robot = ","proc = ","job = ","input = ","params = "])
except getopt.GetoptError:
print ('robot.py -r <robot> -p <proc> -j <job>')
for opt, arg in opts:
if opt == '-h':
print ('robot.py -r <robot> -p <proc> -j <job>')
elif opt in ("-r", "--robot"):
robot_no = arg
elif opt in ("-p", "--proc"):
proc_no = arg
elif opt in ("-j", "--job"):
job_no = arg
elif opt in ("-i", "--input"):
input_arg = arg
elif opt in ("-c", "--params"):
cParam: dict = iinput.parseConnectorParam(arg)
params = cParam.pop("Params", params)
fn = cParam.pop("FlowName", fn)
pro = AutoRpa(robot_no=robot_no,proc_no=proc_no,job_no=job_no,input_arg=input_arg)
iplatform.Return(MainResult=getattr(pro, fn)(**params), job_no=job_no, fn=fn, flow_return_params=flow_params.get(fn,[]))

View File

@ -0,0 +1,12 @@
# coding: utf8
"""
Python模块脚本
"""
import os
# 项目路径、资源库元素文件(建议其他元素库文件路径在此处声明)
ProjectPath = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
CodePath = os.path.join(ProjectPath, "codes")
ModulePath = os.path.join(CodePath, "modules")
ElementFile = os.path.join(ModulePath, "resources", "elements.json")

View File

@ -0,0 +1,86 @@
# 更多示例代码 http://support.i-search.com.cn:8088/showdoc/web/#/page/11923
## 导入资源库
'''
from iresource import iSResource
is_resource: iSResource = iSResource(__file__)
或者手动指定其他工程元素库文件(当前项目或其他项目)
ElementFile = r"~/NewProject1/codes/modules/resources/elements.json"
is_resource: iSResource = iSResource(json_file=ElementFile)
'''
## 全部资源
'''
all_resource = is_resource.all()
'''
## 获取全局变量、流程变量(参数)
'''
gv1 = is_resource.get_global_var("gv_1")
lv1 = is_resource.get_flow_var("lv_1")
'''
## 设置全局变量、流程变量(参数)
'''
gv1 = is_resource.set_global_var("gv_1", "gv_1新值")
lv1 = is_resource.set_flow_var("lv_1", "lv_1新值")
'''
## 获取元素对象(这段代码可从【元素库】中右击元素,点击【复制代码】直接获得)
'''
element1 = is_resource.get_element("元素ID")
element1.show() # 打印显示元素信息
all_element = is_resource.elements() # 全部元素
'''
## 支持 By.ID,By.CODE,By.NAME 获取元素对象
'''
is_resource.get_element(By.ID, "元素ID")
is_resource.get_element(By.CODE, "元素Code")
is_resource.get_element(By.NAME, "元素Name")
## 也支持具体方法获取元素对象
'''
is_resource.get_element_by_id("元素id")
is_resource.get_element_by_id("元素Code")
is_resource.get_element_by_id("元素Name")
'''
## 元素对象的 方法 和 属性
'''
element1.actions() # 所有方法
element1.properties() # 所有属性
element1.path # 元素路径
element1.snapshot # 元素快照
element1.help("click") # 查看方法使用文档
element1.click(wait_seconds=5)
is_resource.set_action_wait_seconds(5) # 全局调整动作方法执行的等待时间秒数
element1.set_text(value="测试一下")
'''
## 对象化使用
'''
# 使用对象库,通过iSObject对象可以调用子对象(如:键盘、窗口、Excel等)
iSObject.Keyboard(element1.window_title).keypress_input("abc") # 模拟按键
iSObject.Server().set_var("abc", "123") # 设置服务器变量
'''

View File

@ -0,0 +1,3 @@
{
"version": "2.0"
}

View File

@ -0,0 +1,148 @@
# coding=utf-8
"""合同起草真实入口。Studio 编译不会改这个文件。"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def _repo_root() -> Path:
for parent in Path(__file__).resolve().parents:
if (parent / "rpa_yisaiqi" / "__init__.py").exists():
return parent
raise RuntimeError("找不到仓库根目录(缺少 rpa_yisaiqi/__init__.py)")
_REPO_ROOT = _repo_root()
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter # noqa: E402
from rpa_yisaiqi.callback_client import StateCenterClient # noqa: E402
from rpa_yisaiqi.contract_create_node import ( # noqa: E402
ContractCreateError,
build_spec_from_data,
load_input_data,
)
from rpa_yisaiqi.contract_flow_steps import ( # noqa: E402
enter_contract_create,
fill_contract_amount_and_dates,
fill_contract_basic_info,
fill_contract_goods_lines,
fill_contract_payment_info,
fill_contract_signatory_info,
login_contract_system,
open_contract_system,
read_contract_number,
select_contract_template,
submit_contract_for_approval,
upload_contract_document_if_needed,
)
def _first(*values) -> str:
for value in values:
if value is not None and str(value).strip():
return str(value).strip()
return ""
def _parse_input_arg(argv: list[str]) -> str:
i = 0
while i < len(argv):
arg = argv[i]
if arg in ("-i", "--input", "--input-json") and i + 1 < len(argv):
return argv[i + 1]
if arg.startswith("--input=") or arg.startswith("--input-json="):
return arg.split("=", 1)[1]
i += 1
return ""
def main() -> int:
print("合同起草流程启动", flush=True)
raw = _parse_input_arg(sys.argv[1:])
if not raw:
raw = os.getenv("CONTRACT_INPUT_JSON", "").strip()
if not raw:
raw = str(_REPO_ROOT / "rpa_yisaiqi" / "examples" / "contract_create_input_dry_run.json")
print("未传入 -i/--input,使用默认联调入参:", raw, flush=True)
print("[S01 初始化]", flush=True)
data = load_input_data(raw)
state_center_url = _first(
data.get("stateCenterUrl"),
data.get("stateCenterURL"),
os.getenv("STATE_CENTER_URL"),
"http://192.168.105.69:8000",
)
agent_token = _first(data.get("agentToken"), os.getenv("AGENT_TOKEN"), "dev-agent-token")
spec = build_spec_from_data(data)
ctx = spec.flow_context
if ctx is None:
raise ContractCreateError("初始化失败:flow_context 为空")
client = StateCenterClient(state_center_url, agent_token)
browser = ApaBrowserAdapter(url=ctx.contract_url, flow="CONTRACT_CREATE")
is_dry_run = spec.submit_mode == "dry_run"
print(" jobId=", spec.job_id, " submitMode=", spec.submit_mode, flush=True)
print(" contractDocumentPath=", ctx.contract_document_path or ctx.merged_pdf_path, flush=True)
if not is_dry_run:
print("回调状态中心 start", spec.job_id, flush=True)
client.start_node(spec.job_id, "CONTRACT_CREATE")
print("[S02 打开合同系统]", flush=True)
open_contract_system(browser, ctx)
print("[S03 登录合同系统]", flush=True)
login_contract_system(browser, ctx)
print("[S04 进入合同起草]", flush=True)
enter_contract_create(browser)
print("[S05 选择模板]", flush=True)
select_contract_template(browser, ctx)
print("[S06 填写签约方]", flush=True)
fill_contract_signatory_info(browser, ctx)
print("[S07 填写基础信息]", flush=True)
fill_contract_basic_info(browser, ctx)
print("[S08 填写金额日期]", flush=True)
fill_contract_amount_and_dates(browser, ctx)
print("[S09 填写货品明细]", flush=True)
fill_contract_goods_lines(browser, ctx)
print("[S10 填写付款信息]", flush=True)
fill_contract_payment_info(browser, ctx)
print("[S11 上传合同PDF]", flush=True)
upload_contract_document_if_needed(browser, ctx)
print("[S12 提交送审]", flush=True)
submitted = submit_contract_for_approval(browser, ctx)
contract_no = ""
if submitted:
print("[S13 读取合同编号]", flush=True)
contract_no = read_contract_number(browser, ctx)
print("[S14 结束]", flush=True)
if not is_dry_run and contract_no:
print("回调状态中心 complete", spec.job_id, contract_no, flush=True)
client.complete_node(
spec.job_id,
"CONTRACT_CREATE",
{"businessRefs": {"contractSystemNo": contract_no}},
)
print(json.dumps({"status": "done", "contractSystemNo": contract_no}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,64 @@
# coding=utf-8
"""APA 启动 Main.py 前会加载本文件。Studio 重新编译不会改这里。
Studio 点运行只带 -p,不带 -i。这里自动补上默认联调 JSON,
避免 S01 因 input_arg 为空直接报「缺少业务入参」。
账号过期把 Main.py 覆盖成空注释时,改走 run_contract.py。
"""
from __future__ import annotations
import os
import sys
def _codes_dir() -> str:
return os.path.dirname(os.path.abspath(__file__))
def _default_input_path() -> str:
repo = os.path.abspath(os.path.join(_codes_dir(), "..", "..", ".."))
return os.path.join(repo, "rpa_yisaiqi", "examples", "contract_create_input_dry_run.json")
def _is_apa_main() -> bool:
if not sys.argv:
return False
return os.path.basename(str(sys.argv[0])).lower() == "main.py"
def _argv_has_input(argv: list) -> bool:
for i, arg in enumerate(argv):
if arg in ("-i", "--input") and i + 1 < len(argv) and str(argv[i + 1]).strip():
return True
if str(arg).startswith("--input=") and str(arg).split("=", 1)[1].strip():
return True
return False
def _main_is_expired_stub(path: str) -> bool:
try:
text = open(path, encoding="utf-8").read()
except Exception:
return False
return "账号过期" in text
# Windows 虚拟机只回调 Mac 状态中心,不在本机起库。
os.environ.setdefault("STATE_CENTER_URL", "http://192.168.105.69:8000")
os.environ.setdefault("AGENT_TOKEN", "dev-agent-token")
os.environ.setdefault("RPA_CALLBACK_STATE_CENTER", "1")
if _is_apa_main():
if _main_is_expired_stub(sys.argv[0]):
print("检测到 Main.py 被艺赛旗覆盖(账号过期),改走独立入口", flush=True)
if _codes_dir() not in sys.path:
sys.path.insert(0, _codes_dir())
from run_contract import main
raise SystemExit(main())
if not _argv_has_input(sys.argv):
default_input = _default_input_path()
if os.path.isfile(default_input):
sys.argv.extend(["-i", default_input])
print("未传入 -i,自动使用默认联调入参:", default_input, flush=True)

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,33 @@
from pathlib import Path
from rpa_yisaiqi.apa_browser_adapter import REPO_ROOT, resolve_chrome_profile_base
def test_default_chrome_profile_is_outside_repo():
profile = resolve_chrome_profile_base(requested="")
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
assert REPO_ROOT.resolve() not in profile.parents
assert profile != REPO_ROOT.resolve()
def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch):
inside = REPO_ROOT / "rpa_yisaiqi" / "runtime" / "chrome-profile"
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
profile = resolve_chrome_profile_base(requested=str(inside))
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve()
assert not _is_relative_to(profile, REPO_ROOT)
def test_env_path_outside_repo_is_kept(tmp_path):
outside = tmp_path / "custom-chrome"
outside.mkdir()
profile = resolve_chrome_profile_base(requested=str(outside))
assert profile == outside.resolve()
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True

View File

@ -0,0 +1,59 @@
from rpa_yisaiqi.runtime.accept import (
AcceptanceReport,
accept_log_path,
artifact_dir,
evidence_shot,
field_match,
)
from rpa_yisaiqi.runtime.errors import NodeError
import pytest
def test_each_flow_writes_its_own_accept_file(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
monkeypatch.setattr(
"rpa_yisaiqi.runtime.accept.SCREENSHOT_ROOT",
tmp_path,
)
report = AcceptanceReport(flow="ORDER_FETCH")
report.note("S01", "登录", {"user": "u1"}, ok=True)
path = accept_log_path("ORDER_FETCH")
assert path == tmp_path / "ORDER_FETCH" / "ACCEPT.txt"
text = path.read_text(encoding="utf-8")
assert "ACCEPT SUMMARY ORDER_FETCH 1/1 ok" in text
assert "ACCEPT S01 ok 登录 user=u1" in text
def test_artifact_dir_is_per_flow():
assert artifact_dir("REBATE_HEADER").name == "REBATE_HEADER"
assert artifact_dir("M5_CREATE_ORDER").name == "M5_CREATE_ORDER"
assert artifact_dir("CONTRACT_CREATE") != artifact_dir("ORDER_FETCH")
def test_check_fails_on_mismatch_without_reading_images():
report = AcceptanceReport(flow="REBATE_HEADER")
with pytest.raises(NodeError, match="ACCEPT S02 FAIL"):
report.check("S02", "总单号", [("rebateOrderNo", "RB-1", "RB-2")])
def test_evidence_shot_skips_success_and_keeps_failure():
class Fake:
def __init__(self):
self.names = []
def screenshot(self, name):
self.names.append(name)
return name
browser = Fake()
assert evidence_shot(browser, "ok") == ""
assert browser.names == []
assert evidence_shot(browser, "bad", failed=True) == "bad"
assert browser.names == ["bad"]
def test_field_match_accepts_numeric_and_yes_no():
assert field_match("12.0", "12")
assert field_match("是", "1")
assert field_match("2026-08-11", "20260811")
assert not field_match("重庆其他", "重庆亿昊药业有限公司")

View File

@ -37,6 +37,6 @@ class YiSaiQiBrowserAdapter:
raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}") raise NotImplementedError(f"Map to YiSaiQi read-text activity: {selectors=} {value_name=}")
def screenshot(self, name: str) -> str: def screenshot(self, name: str) -> str:
"""Flowchart node: 截图存证,返回文件路径.""" """Human evidence only. Acceptance is ACCEPT text from runtime.accept, not this image."""
raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}") raise NotImplementedError(f"Map to YiSaiQi screenshot activity: {name=}")

View File

@ -3,13 +3,15 @@ from pathlib import Path
import pytest import pytest
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
from rpa_yisaiqi.contract_create_node import ( from rpa_yisaiqi.contract_create_node import (
ContractCreateError, ContractCreateError,
ContractCreateInput, ContractCreateInput,
PdfMergeUnsupported, PdfMergeUnsupported,
build_spec_from_data,
create_contract_in_contract_system, create_contract_in_contract_system,
) )
from rpa_yisaiqi.contract_draft import load_contract_draft from rpa_yisaiqi.contract_draft import is_placeholder_customer_code, load_contract_draft
from rpa_yisaiqi.pdf_utils import ensure_merged_pdf from rpa_yisaiqi.pdf_utils import ensure_merged_pdf
@ -77,6 +79,33 @@ def test_load_contract_draft_maps_full_contract_inputs(tmp_path):
assert draft.goods_lines[0].goods_code == "YP-001" assert draft.goods_lines[0].goods_code == "YP-001"
def test_placeholder_customer_code_is_rejected_before_opening_browser():
assert is_placeholder_customer_code("CUST-YYC-001")
assert is_placeholder_customer_code("test-001")
assert not is_placeholder_customer_code("DW0100046361")
with pytest.raises(ContractCreateError, match="占位符"):
build_spec_from_data(
{
"jobId": "JOB-1",
"supplierName": "重庆亿昊药业有限公司",
"orderDate": "2026-08-11",
"sourceType": "XYY",
"customerCode": "CUST-YYC-001",
"customerName": "重庆亿昊药业有限公司",
"amount": "100",
"submitMode": "dry_run",
"goodsLines": [
{
"goodsCode": "YP-001",
"goodsName": "阿卡波糖片",
"quantity": 1,
}
],
}
)
def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path): def test_yyc_requires_pdf_inputs_before_contract_drafting(tmp_path):
draft_path = tmp_path / "draft.json" draft_path = tmp_path / "draft.json"
draft_path.write_text( draft_path.write_text(
@ -111,14 +140,16 @@ def test_ensure_merged_pdf_uses_single_pdf_without_merge(tmp_path):
assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source) assert ensure_merged_pdf([str(source)], str(tmp_path / "merged.pdf")) == str(source)
def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path): def test_create_contract_uses_apa_browser_adapter(tmp_path, monkeypatch):
draft_path = tmp_path / "draft.json" draft_path = tmp_path / "draft.json"
draft_path.write_text( draft_path.write_text(
json.dumps( json.dumps(
{ {
"supplierName": "小药药供应商", "supplierName": "重庆亿昊药业有限公司",
"orderDate": "2026-08-11", "orderDate": "2026-08-11",
"sourceType": "XYY", "sourceType": "XYY",
"customerCode": "DW0100046361",
"customerName": "重庆亿昊药业有限公司",
"goodsLines": [], "goodsLines": [],
"platformPdfPaths": [], "platformPdfPaths": [],
}, },
@ -128,11 +159,22 @@ def test_real_drafting_requires_yisaiqi_adapter_mapping(tmp_path):
) )
spec = ContractCreateInput( spec = ContractCreateInput(
job_id="JOB-1", job_id="JOB-1",
supplier_name="小药药供应商", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="XYY", source_type="XYY",
draft_path=str(draft_path), draft_path=str(draft_path),
) )
captured = {}
with pytest.raises(ContractCreateError, match="YiSaiQiBrowserAdapter"): def fake_run(browser, ctx):
create_contract_in_contract_system(spec) captured["browser"] = browser
captured["ctx"] = ctx
return "HT-1"
monkeypatch.setattr(
"rpa_yisaiqi.contract_create_node.run_contract_create_flow",
fake_run,
)
assert create_contract_in_contract_system(spec) == "HT-1"
assert isinstance(captured["browser"], ApaBrowserAdapter)

View File

@ -1,110 +1,269 @@
import pytest
from rpa_yisaiqi.contract_flow_steps import ( from rpa_yisaiqi.contract_flow_steps import (
ContractFlowContext, ContractFlowContext,
ContractGoodsLine, ContractGoodsLine,
fill_contract_signatory_info,
run_contract_create_flow, run_contract_create_flow,
) )
from rpa_yisaiqi.runtime.errors import SupplierContractDisabled
@pytest.fixture(autouse=True)
def no_sleep(monkeypatch):
monkeypatch.setattr("rpa_yisaiqi.contract_flow_steps.time.sleep", lambda _s: None)
class FakeBrowser: class FakeBrowser:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
self.body_text = ""
self.xform_labels = {}
self.filled = {}
self.read_values = { self.read_values = {
"contract_number": "HT-20260811-REAL-001", "contract_number": "HT-20260811-REAL-001",
} }
def open_url(self, url): def open_url(self, url):
self.calls.append(("open_url", url)) self.calls.append(("open_url", url))
if "kmAgreementApply" in url or "draft.jsp" in url:
self.body_text = "客户编码 客户名称 合同类别"
def fill_first(self, selectors, value): def fill_first(self, selectors, value):
self.calls.append(("fill_first", tuple(selectors), value)) self.calls.append(("fill_first", tuple(selectors), value))
for selector in selectors:
self.filled[selector] = value
def click_native(self, selectors):
self.calls.append(("click_native", tuple(selectors)))
def click_first(self, selectors): def click_first(self, selectors):
self.calls.append(("click_first", tuple(selectors))) self.calls.append(("click_first", tuple(selectors)))
joined = " ".join(str(item) for item in selectors)
if "login" in joined or "登录" in joined:
self.body_text = "切换门户 待办 待阅 合同管理 合同起草"
def wait_for_text(self, text, timeout_seconds=15): def wait_for_text(self, text, timeout_seconds=15):
self.calls.append(("wait_for_text", text, timeout_seconds)) self.calls.append(("wait_for_text", text, timeout_seconds))
if text and text not in self.body_text:
self.body_text = f"{self.body_text} {text}".strip()
def wait_for_element(self, selectors, timeout_seconds=15):
self.calls.append(("wait_for_element", tuple(selectors), timeout_seconds))
def element_exists(self, selectors):
self.calls.append(("element_exists", tuple(selectors)))
return True
def switch_to_frame(self, selectors):
self.calls.append(("switch_to_frame", tuple(selectors)))
def switch_to_default_content(self):
self.calls.append(("switch_to_default_content",))
def select_first(self, selectors, value): def select_first(self, selectors, value):
self.calls.append(("select_first", tuple(selectors), value)) self.calls.append(("select_first", tuple(selectors), value))
for selector in selectors:
self.filled[selector] = value
def select_xform(self, name, value):
self.calls.append(("select_xform", name, value))
self.xform_labels[name] = value
def execute_script(self, script, *args):
self.calls.append(("execute_script", script[:48], args))
if "hasTextInFrames" in script:
return True
if "latestByApplyTime" in script and "headerIdx" in script:
return "001132026081734940"
if "wantedTitle" in script and "headerIdx" in script:
return "001132026081734940"
if "fd_opcode" in script and "return i" in script:
return 0
if "List_Selected" in script and "innerText" in script:
token = str(args[0]) if args else ""
if token.startswith("DW"):
self.filled['input[name="extendDataFormInfo.value(fd_customeropcode)"]'] = token
elif token:
self.filled['input[name="extendDataFormInfo.value(fd_detail1.0.fd_opcode)"]'] = token
if "box.click" in script:
return {"ok": True}
return True
if "marked" in script and "fd_detail1" in script:
return []
if "return location.href" in script:
return "http://contract-test.gzmpc.com:8080/km/agreement/km_agreement_apply/kmAgreementApply.do?method=view"
if "Com_Parameter" in script and "fdId" in script:
return "FD-TEST-001"
if "添加合同" in script and ("pdf" in script or "wantedStem" in script):
if any(call[0] == "upload_file_first" for call in self.calls):
return {"ok": True, "via": "name"}
return {"ok": False, "via": "empty"}
if "提交" in script or "合同正文" in script or "确定" in script:
return {"ok": True}
if "fs-label" in script and args:
return self.xform_labels.get(args[0], "")
if "data-value" in script:
return "模板A"
return []
def upload_file_first(self, selectors, file_path): def upload_file_first(self, selectors, file_path):
self.calls.append(("upload_file_first", tuple(selectors), file_path)) self.calls.append(("upload_file_first", tuple(selectors), file_path))
def read_first(self, selectors, value_name): def read_first(self, selectors, value_name):
self.calls.append(("read_first", tuple(selectors), value_name)) self.calls.append(("read_first", tuple(selectors), value_name))
return self.read_values[value_name] if value_name in self.read_values:
return self.read_values[value_name]
for selector in selectors:
if selector in self.filled:
return self.filled[selector]
joined = " ".join(str(item) for item in selectors)
if "fd_customername" in joined:
return self.read_values.get("customer_name", "重庆亿昊药业有限公司")
if "fd_customeropcode" in joined:
return self.read_values.get("customer_code", self.filled.get(
'input[name="extendDataFormInfo.value(fd_customeropcode)"]', ""
))
if "fdSettype" in joined:
return self.read_values.get("settlement_type", "付款")
return ""
def read_body_text(self):
self.calls.append(("read_body_text",))
return self.body_text
def screenshot(self, name): def screenshot(self, name):
self.calls.append(("screenshot", name)) self.calls.append(("screenshot", name))
return f"/tmp/{name}.png" return f"/tmp/{name}.png"
def test_signatory_rejects_placeholder_customer_before_opening_picker():
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",
customer_code="CUST-YYC-001",
customer_name="重庆亿昊药业有限公司",
)
with pytest.raises(SupplierContractDisabled, match="占位符"):
fill_contract_signatory_info(browser, ctx)
assert browser.calls == []
def test_contract_flow_logs_in_with_real_contract_login_selectors(): def test_contract_flow_logs_in_with_real_contract_login_selectors():
browser = FakeBrowser() browser = FakeBrowser()
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="合同节点联调供应商", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="XYY", source_type="XYY",
template_type="FORMAT", template_type="FORMAT",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
goods_lines=[], goods_lines=[],
) )
run_contract_create_flow(browser, ctx) run_contract_create_flow(browser, ctx)
assert browser.calls[:5] == [ assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls
("open_url", "http://contract-test.gzmpc.com:8080/"), assert ("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002") in browser.calls
("fill_first", ('input[name="j_username"]', 'input[placeholder="用户名"]'), "test002"), assert ("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1") in browser.calls
("fill_first", ('input[name="j_password"]', 'input[placeholder="密码"]'), "1"), assert (
("click_first", ('input[name="btn_submit"]', 'a[href*="btn_submit"]', 'text=登录')), "click_first",
("wait_for_text", "合同", 20), ('a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'),
] ) in browser.calls
assert not [call for call in browser.calls if call[0] == "screenshot"]
def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(): def _touch_pdf(path):
path.write_bytes(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
return str(path)
def test_contract_flow_uses_order_date_and_uploads_yyc_merged_pdf(tmp_path):
browser = FakeBrowser() browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
pdf_path = _touch_pdf(tmp_path / "contract-merged.pdf")
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="合同节点联调供应商", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="YYC", source_type="YYC",
template_type="FORMAT", template_type="FORMAT",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
goods_lines=[ goods_lines=[
ContractGoodsLine( ContractGoodsLine(
operation_code="ADD", operation_code="ADD",
goods_code="YP-001", goods_code="FQX0100118047",
goods_name="阿卡波糖片", goods_name="盐酸托烷司琼注射液",
approval_no="国药准字H00000001", approval_no="苏20160372",
spec="50mg*30片", spec="5ml:5mg*5支",
unit="盒", unit="盒",
quantity=12, quantity=12,
unit_price="50.00",
) )
], ],
merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", merged_pdf_path=pdf_path,
) )
contract_no = run_contract_create_flow(browser, ctx) contract_no = run_contract_create_flow(browser, ctx)
assert contract_no == "HT-20260811-REAL-001" assert contract_no == "001132026081734940"
assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls
assert ("fill_first", ('label=合同开始日期', 'input[name="fdStartDate"]', 'input[placeholder*="合同开始"]'), "2026-08-11") in browser.calls assert ("fill_first", ('input[name="fdEffedate"]',), "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 assert (
"click_first",
('div[title="选择商品"]', "div.lui-component.lui_widget_btn_txt", "text=选择商品"),
) in browser.calls
assert (
"switch_to_frame",
(
'iframe[src*="EopBasedataMaterial"]',
'iframe[src*="dialog_fdMaterial"]',
'iframe[src*="common-template"]',
),
) in browser.calls
assert ("fill_first", ("input.search-box-text", "input[placeholder*='关键字']"), "FQX0100118047") in browser.calls
assert (
"fill_first",
('input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)"]',),
"12",
) in browser.calls
assert (
"fill_first",
('input[name="extendDataFormInfo.value(fd_detail1.0.fd_unitprice)"]',),
"50.00",
) in browser.calls
assert any(
call[0] == "upload_file_first" and call[-1] == pdf_path
for call in browser.calls
)
def test_contract_flow_skips_pdf_upload_for_xyy(): def test_contract_flow_skips_pdf_upload_for_xyy():
browser = FakeBrowser() browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="小药药供应商", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="XYY", source_type="XYY",
template_type="FORMAT", template_type="FORMAT",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
goods_lines=[], goods_lines=[],
merged_pdf_path="C:/should-not-upload.pdf", merged_pdf_path="C:/should-not-upload.pdf",
) )
@ -114,45 +273,46 @@ def test_contract_flow_skips_pdf_upload_for_xyy():
assert not [call for call in browser.calls if call[0] == "upload_file_first"] 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(): def test_contract_flow_enters_draft_form_by_template_url(tmp_path):
from rpa_yisaiqi.contract_flow_steps import FORM_PAGE_URL
browser = FakeBrowser() browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="重庆亿昊药业", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="YYC", source_type="YYC",
template_type="NON_FORMAT", template_type="NON_FORMAT",
template_name="非格式--采购协议", template_name="非格式--采购协议",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
goods_lines=[], goods_lines=[],
merged_pdf_path="C:/rpa/jobs/JOB-1/contract-merged.pdf", merged_pdf_path=_touch_pdf(tmp_path / "contract-merged.pdf"),
) )
run_contract_create_flow(browser, ctx) run_contract_create_flow(browser, ctx)
assert ("click_first", ("text=合同管理", "aria=合同管理")) in browser.calls assert ("open_url", FORM_PAGE_URL) in browser.calls
assert ("click_first", ("text=合同起草", "aria=合同起草")) in browser.calls assert ("wait_for_text", "客户编码", 20) 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(): def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot():
browser = FakeBrowser() browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="重庆亿昊药业", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="XYY", source_type="XYY",
template_type="NON_FORMAT", template_type="NON_FORMAT",
template_name="非格式--采购协议", template_name="非格式--采购协议",
customer_code="CUST-001", customer_code="DW0100046361",
customer_name="重庆亿昊药业", customer_name="重庆亿昊药业有限公司",
contract_category="采购协议", contract_category="采购协议",
settlement_type="付款", settlement_type="付款",
contract_background="按订单自动起草采购协议", contract_background="按订单自动起草采购协议",
@ -172,37 +332,53 @@ def test_contract_flow_fills_real_contract_draft_form_fields_from_screenshot():
run_contract_create_flow(browser, ctx) run_contract_create_flow(browser, ctx)
assert ("fill_first", ('label=客户编码', 'input[name="fdCustomerCode"]', 'input[placeholder*="客户编码"]'), "CUST-001") in browser.calls assert (
assert ("fill_first", ('label=客户名称', 'input[name="fdCustomerName"]', 'input[placeholder*="客户名称"]'), "重庆亿昊药业") in browser.calls "select_xform",
assert ("select_first", ('label=合同类别', 'select[name="fdContractCategory"]'), "采购协议") in browser.calls "extendDataFormInfo.value(fd_contract_cate_type)",
assert ("select_first", ('label=结算类型', 'select[name="fdSettlementType"]'), "付款") in browser.calls "其他协议(采购)",
assert ("fill_first", ('label=合同背景概述', 'textarea[name="fdContractBackground"]'), "按订单自动起草采购协议") in browser.calls ) in browser.calls
assert ("click_first", ("label=主合同", "text=主合同")) in browser.calls assert ("select_first", ('select#fdSettype',), "付款") in browser.calls
assert ("select_first", ('label=合同金额', 'select[name="fdCurrency"]'), "人民币") in browser.calls assert (
assert ("fill_first", ('label=金额(元)', 'input[name="fdAmount"]'), "12000.50") in browser.calls "fill_first",
assert ("fill_first", ('label=合同签订日期', 'input[name="fdSignDate"]', 'input[placeholder*="合同签订"]'), "2026-08-11") in browser.calls ('textarea[name="extendDataFormInfo.value(fd_desc_info)"]',),
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 ) in browser.calls
assert ("select_first", ('label=是否重大合同', 'select[name="fdIsMajorContract"]'), "否") in browser.calls assert ("select_xform", "extendDataFormInfo.value(fd_all_amount_type)", "合同标的金额") in browser.calls
assert ("select_first", ('label=是否预付款', 'select[name="fdIsPrepayment"]'), "否") in browser.calls assert ("fill_first", ('input[name="fdAccount"]',), "12000.50") in browser.calls
assert ("fill_first", ('label=合同归属年份', 'input[name="fdBelongYear"]'), "2026") in browser.calls assert ("fill_first", ('input[name="fdSigndate"]',), "2026-08-11") in browser.calls
assert ("select_first", ('label=付款方式', 'select[name="fdPaymentMethod"]', 'select[name="paymentMethod"]'), "账期支付") in browser.calls assert ("fill_first", ('input[name="fdEffedate"]',), "2026-08-11") in browser.calls
assert ("fill_first", ('label=付款期限', 'input[name="fdPaymentDays"]', 'input[name="paymentDays"]'), "30") in browser.calls assert ("fill_first", ('input[name="fdEnddate"]',), "2026-12-31") in browser.calls
assert ("select_first", ('label=合同状态', 'select[name="fdContractStatus"]'), "正式合同") in browser.calls assert ("select_xform", "extendDataFormInfo.value(fd_is_major_contract)", "否") in browser.calls
assert ("select_first", ('label=是否包含血液制品', 'select[name="fdContainsBloodProducts"]'), "否") in browser.calls assert ("select_xform", "extendDataFormInfo.value(fd_advance_amout)", "否") in browser.calls
assert (
"fill_first",
('input[name="extendDataFormInfo.value(fd_contract_year_text)"]',),
"2026",
) in browser.calls
assert ("select_xform", "extendDataFormInfo.value(fd_paytype)", "电汇") in browser.calls
assert (
"fill_first",
('input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',),
"30",
) in browser.calls
assert ("select_xform", "extendDataFormInfo.value(fd_suconstatus)", "正式合同") in browser.calls
assert ("select_xform", "extendDataFormInfo.value(fd_3d1ce3953f1162)", "否") in browser.calls
def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state(): def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state():
browser = FakeBrowser() browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
ctx = ContractFlowContext( ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/", contract_url="http://contract-test.gzmpc.com:8080/",
username="test002", username="test002",
password="1", password="1",
supplier_name="重庆亿昊药业", supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11", order_date="2026-08-11",
source_type="XYY", source_type="XYY",
template_type="NON_FORMAT", template_type="NON_FORMAT",
template_name="非格式--采购协议", template_name="非格式--采购协议",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
submit_mode="dry_run", submit_mode="dry_run",
goods_lines=[], goods_lines=[],
) )
@ -210,6 +386,105 @@ def test_contract_flow_dry_run_skips_submit_but_still_reads_page_state():
contract_no = run_contract_create_flow(browser, ctx) 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 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 not [call for call in browser.calls if call[0] == "screenshot"]
assert contract_no == "" assert contract_no == ""
assert not [call for call in browser.calls if call[0] == "read_first"]
def test_contract_flow_writes_text_accept_report_instead_of_requiring_screenshots(tmp_path, monkeypatch):
from rpa_yisaiqi.contract_flow_steps import _ACCEPT, accept_log_path
monkeypatch.setenv("RPA_ACCEPT_LOG", str(tmp_path / "ACCEPT.txt"))
browser = FakeBrowser()
browser.read_values["customer_name"] = "重庆亿昊药业有限公司"
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",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
submit_mode="dry_run",
goods_lines=[],
)
run_contract_create_flow(browser, ctx)
text = accept_log_path("CONTRACT_CREATE").read_text(encoding="utf-8")
assert "ACCEPT SUMMARY" in text
assert "ACCEPT S03 ok" in text
assert "ACCEPT S06 ok" in text
assert "customerName=重庆亿昊药业有限公司" in text
assert "ACCEPT S12 ok" in text
assert all(line.ok for line in _ACCEPT.lines)
def test_read_contract_number_falls_back_to_my_contracts_list():
from rpa_yisaiqi.contract_flow_steps import read_contract_number
browser = FakeBrowser()
browser.read_values.pop("contract_number", None)
ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/",
username="test002",
password="1",
supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11",
source_type="XYY",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
contract_title="test1",
goods_lines=[],
)
contract_no = read_contract_number(browser, ctx)
assert contract_no == "001132026081734940"
assert ("open_url", "http://contract-test.gzmpc.com:8080/") in browser.calls
assert ("click_first", ("text=我的合同",)) in browser.calls
assert ("click_first", ("text=我起草的",)) in browser.calls
assert ("click_first", ("text=申请时间",)) in browser.calls
def test_read_contract_number_goes_to_my_list_even_without_ctx_arg():
from rpa_yisaiqi import _flow_state as state
from rpa_yisaiqi.contract_flow_steps import read_contract_number
browser = FakeBrowser()
browser.read_values.pop("contract_number", None)
old_ctx = state.ctx
state.ctx = ContractFlowContext(
contract_url="http://contract-test.gzmpc.com:8080/",
username="test002",
password="1",
supplier_name="重庆亿昊药业有限公司",
order_date="2026-08-11",
source_type="XYY",
customer_code="DW0100046361",
customer_name="重庆亿昊药业有限公司",
contract_title="test1",
goods_lines=[],
)
try:
contract_no = read_contract_number(browser)
finally:
state.ctx = old_ctx
assert contract_no == "001132026081734940"
assert ("click_first", ("text=我的合同",)) in browser.calls
assert ("click_first", ("text=申请时间",)) in browser.calls
def test_accept_check_fails_when_page_value_mismatches():
from rpa_yisaiqi.contract_flow_steps import AcceptanceReport
from rpa_yisaiqi.runtime.errors import NodeError
report = AcceptanceReport()
with pytest.raises(NodeError, match="ACCEPT S06 FAIL"):
report.check(
"S06",
"签约方",
[("customerName", "其他公司", "重庆亿昊药业有限公司")],
)