5D3420315FA54219AB2427B0C674AD66auto
This commit is contained in:
parent
0a8d4e8c75
commit
7c9217d197
|
|
@ -5,7 +5,7 @@
|
|||
"ProInfo": {
|
||||
"CodeCustomCount": "0",
|
||||
"ProAbsolutePath": "D:/RPA/AutoRpa",
|
||||
"ProChange": "2026/08/13 10:38:45",
|
||||
"ProChange": "2026/08/13 11:38:45",
|
||||
"ProColorRef": "4",
|
||||
"ProCreate": "2026/08/12 10:13:31",
|
||||
"ProDesc": "",
|
||||
|
|
|
|||
181
build_seq.py
181
build_seq.py
|
|
@ -1,6 +1,6 @@
|
|||
# coding=utf-8
|
||||
"""
|
||||
构建 Main.seq - 合同建单流程(Chrome浏览器版本)
|
||||
构建 Main.seq - 合同建单流程(输入驱动 + 状态中心回调版本)
|
||||
"""
|
||||
import json
|
||||
import base64
|
||||
|
|
@ -11,150 +11,67 @@ def b64_apa(val):
|
|||
def b64_raw(val):
|
||||
return base64.b64encode(str(val).encode()).decode()
|
||||
|
||||
s01_body = """\
|
||||
import sys, os, json, time
|
||||
# 单节点代码块:读取入参(input_arg / -c params / JSON 文件)→ 调用 run_contract_create
|
||||
# run_contract_create 内部完成 start/complete/fail 回调、Selenium 起草与合同编号读取。
|
||||
body = """\
|
||||
import sys, os, json
|
||||
from pathlib import Path
|
||||
|
||||
_CODES_DIR = Path(r'd:\\RPA\\AutoRpa\\codes')
|
||||
if str(_CODES_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_CODES_DIR))
|
||||
|
||||
from rpa_yisaiqi.contract_flow_steps import ContractFlowContext, ContractGoodsLine
|
||||
|
||||
print("=" * 60)
|
||||
print("合同建单 CONTRACT_CREATE - 初始化")
|
||||
print("=" * 60)
|
||||
|
||||
self._ctx = ContractFlowContext(
|
||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||
username="test002", password="1",
|
||||
supplier_name="测试供应商",
|
||||
order_date="2026-08-12",
|
||||
source_type="YYC", template_type="NON_FORMAT",
|
||||
template_name="非格式--采购协议",
|
||||
customer_code="CUST-001", customer_name="测试供应商",
|
||||
contract_category="采购协议", settlement_type="付款",
|
||||
contract_background="自动化测试合同起草", contract_nature="主合同",
|
||||
amount_currency="人民币", amount="50000.00",
|
||||
contract_end_date="2026-12-31",
|
||||
is_major_contract="否", is_prepayment="否",
|
||||
belong_year="2026", payment_method="账期支付",
|
||||
payment_days="30", contract_status="正式合同",
|
||||
contains_blood_products="否",
|
||||
submit_mode="dry_run",
|
||||
goods_lines=[
|
||||
ContractGoodsLine(
|
||||
operation_code="ADD", goods_code="YP-001",
|
||||
goods_name="测试药品A", approval_no="国药准字H00000001",
|
||||
spec="50mg*30片", unit="盒", quantity=10.0,
|
||||
),
|
||||
],
|
||||
merged_pdf_path=None,
|
||||
from rpa_yisaiqi.contract_create_node import (
|
||||
ContractCreateError, load_input_data, run_contract_create,
|
||||
)
|
||||
|
||||
print(f"[OK] 上下文就绪: source={self._ctx.source_type}")
|
||||
print(f"[OK] 模板: {self._ctx.template_name}")
|
||||
print(f"[OK] 货品行数: {len(self._ctx.goods_lines)}")
|
||||
"""
|
||||
def _first(*values):
|
||||
for value in values:
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return ""
|
||||
|
||||
s02_body = """\
|
||||
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
|
||||
|
||||
print("[S02] 启动 Chrome + 打开合同系统...")
|
||||
print(f" URL: {self._ctx.contract_url}")
|
||||
adapter = ApaBrowserAdapter(url=self._ctx.contract_url)
|
||||
adapter.open_url(self._ctx.contract_url)
|
||||
self._adapter = adapter
|
||||
print("[OK] Chrome 已打开并导航到合同系统")
|
||||
"""
|
||||
|
||||
s03_body = """\
|
||||
from rpa_yisaiqi.contract_flow_steps import login_contract_system, enter_contract_create
|
||||
|
||||
adapter = self._adapter
|
||||
print("[S03] 登录合同系统...")
|
||||
login_contract_system(adapter, self._ctx)
|
||||
print("[OK] 登录完成")
|
||||
print("[S04] 进入合同起草...")
|
||||
enter_contract_create(adapter)
|
||||
print("[OK] 已进入合同起草页面")
|
||||
"""
|
||||
|
||||
s04_body = """\
|
||||
from rpa_yisaiqi.contract_flow_steps import select_contract_template, fill_contract_signatory_info
|
||||
|
||||
adapter = self._adapter
|
||||
|
||||
print("[S05] 选择合同模板...")
|
||||
select_contract_template(adapter, self._ctx)
|
||||
print(f"[OK] 模板: {self._ctx.template_name}")
|
||||
print("[S06] 填写签约方信息...")
|
||||
fill_contract_signatory_info(adapter, self._ctx)
|
||||
print(f"[OK] 签约方: {self._ctx.customer_name}")
|
||||
"""
|
||||
|
||||
s05_body = """\
|
||||
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info, fill_contract_amount_and_dates
|
||||
|
||||
adapter = self._adapter
|
||||
|
||||
print("[S07] 填写基础表单...")
|
||||
fill_contract_basic_info(adapter, self._ctx)
|
||||
print(f"[OK] 类别={self._ctx.contract_category}, 结算={self._ctx.settlement_type}")
|
||||
print("[S08] 填写金额与日期...")
|
||||
fill_contract_amount_and_dates(adapter, self._ctx)
|
||||
print(f"[OK] 金额={self._ctx.amount}, 结束日期={self._ctx.contract_end_date}")
|
||||
"""
|
||||
|
||||
s06_body = """\
|
||||
from rpa_yisaiqi.contract_flow_steps import (
|
||||
fill_contract_goods_lines, fill_contract_payment_info,
|
||||
upload_contract_document_if_needed, submit_contract_for_approval,
|
||||
read_contract_number
|
||||
)
|
||||
|
||||
adapter = self._adapter
|
||||
|
||||
print("[S09] 填写货品明细...")
|
||||
fill_contract_goods_lines(adapter, self._ctx)
|
||||
print(f"[OK] 货品: {len(self._ctx.goods_lines)} 行")
|
||||
print("[S10] 填写付款信息...")
|
||||
fill_contract_payment_info(adapter, self._ctx)
|
||||
print(f"[OK] 付款方式={self._ctx.payment_method}")
|
||||
|
||||
if self._ctx.source_type == "YYC":
|
||||
print("[S11] 合并PDF [YYC专属]... (单PDF, 跳过合并)")
|
||||
print("[S12] 上传PDF附件 [YYC专属]... (dry_run 跳过)")
|
||||
|
||||
print()
|
||||
print("[S13] 提交审批...")
|
||||
if not submit_contract_for_approval(adapter, self._ctx):
|
||||
print("[DRY-RUN] 演练模式,跳过实际提交")
|
||||
print("=" * 60)
|
||||
print("合同建单流程演练完成")
|
||||
print("=" * 60)
|
||||
raw = (self.input_arg or "").strip()
|
||||
if raw:
|
||||
data = load_input_data(raw)
|
||||
elif isinstance(params, dict) and params:
|
||||
nested = params.get("inputJson") or params.get("inputArg") or params.get("input")
|
||||
data = load_input_data(str(nested)) if nested else dict(params)
|
||||
else:
|
||||
print("[S14] 读取合同编号...")
|
||||
contract_no = read_contract_number(adapter)
|
||||
print(f"[OK] 合同编号: {contract_no}")
|
||||
print("=" * 60)
|
||||
print(f"[COMPLETED] 合同创建成功: {contract_no}")
|
||||
print("=" * 60)
|
||||
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径,或通过 -c/--params 传入参数")
|
||||
|
||||
state_center_url = _first(
|
||||
data.get("stateCenterUrl"), data.get("stateCenterURL"),
|
||||
os.getenv("STATE_CENTER_URL"), params.get("stateCenterUrl"),
|
||||
"http://127.0.0.1:8000",
|
||||
)
|
||||
agent_token = _first(
|
||||
data.get("agentToken"), os.getenv("AGENT_TOKEN"),
|
||||
params.get("agentToken"), "dev-agent-token",
|
||||
)
|
||||
|
||||
print("=" * 60)
|
||||
print("合同建单 CONTRACT_CREATE - 开始")
|
||||
print("jobId=%s sourceType=%s submitMode=%s" % (
|
||||
data.get("jobId"), data.get("sourceType"), data.get("submitMode")))
|
||||
print("=" * 60)
|
||||
|
||||
result = run_contract_create(
|
||||
data,
|
||||
state_center_url=state_center_url,
|
||||
agent_token=agent_token,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
"""
|
||||
|
||||
steps = [
|
||||
("S01 初始化 & 上下文构建", s01_body, "设置环境变量并构建YYC测试上下文"),
|
||||
("S02 启动Chrome", s02_body, "Selenium打开Chrome浏览器并导航到合同系统"),
|
||||
("S03-S04 登录 & 进入起草", s03_body, "登录系统并进入合同起草页面"),
|
||||
("S05-S06 模板选择 & 签约方", s04_body, "选择模板并填写签约方信息"),
|
||||
("S07-S08 基础表单 & 金额日期", s05_body, "填写合同类别、金额、日期"),
|
||||
("S09-S15 货品/付款/提交", s06_body, "填写货品明细、付款信息并提交"),
|
||||
("读取入参并执行合同建单", body, "读取艺赛旗入参并执行 CONTRACT_CREATE(含状态中心回调)"),
|
||||
]
|
||||
|
||||
nodes = []
|
||||
lines = []
|
||||
|
||||
begin_tag = "20260812100000"
|
||||
begin_tag = "20260813100000"
|
||||
nodes.append({
|
||||
"NodePanelData": {
|
||||
"ExpandedGroups": [], "InputProperty": {"mouse_x": 0, "mouse_y": 0},
|
||||
|
|
@ -174,12 +91,12 @@ start_x = 500
|
|||
start_y = 200
|
||||
space_y = 120
|
||||
|
||||
for i, (title, body, note) in enumerate(steps):
|
||||
tag = f"20260812{200000 + i:06d}"
|
||||
for i, (title, body_text, note) in enumerate(steps):
|
||||
tag = f"20260813{200000 + i:06d}"
|
||||
py = start_y + i * space_y
|
||||
|
||||
pimport_b64 = b64_raw("")
|
||||
pbody_b64 = b64_raw(body)
|
||||
pbody_b64 = b64_raw(body_text)
|
||||
|
||||
props = {
|
||||
"EP_CONTROL": {
|
||||
|
|
@ -218,9 +135,9 @@ for i, (title, body, note) in enumerate(steps):
|
|||
"LineStart": {
|
||||
"Pidx": f"{prev_tag}/EPin_Flow_Out",
|
||||
"Px": str(start_x - 100) if i > 0 else "415",
|
||||
"Py": str(start_y + 30) if i == 0 else str(start_y + (i-1) * space_y + 30)
|
||||
"Py": str(start_y + 30) if i == 0 else str(start_y + (i - 1) * space_y + 30)
|
||||
},
|
||||
"LineTag": f"20260812{300000 + i:06d}"
|
||||
"LineTag": f"20260813{300000 + i:06d}"
|
||||
})
|
||||
|
||||
prev_tag = tag
|
||||
|
|
|
|||
158
codes/Main.py
158
codes/Main.py
|
|
@ -41,123 +41,65 @@ class AutoRpa:
|
|||
if self.input_arg is None:
|
||||
sys.exit(0)
|
||||
|
||||
def Main(self):
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200000,Title:插入代码块#1,Note:设置环境变量并构建YYC测试上下文')
|
||||
import sys, os, json, time
|
||||
def _resolve_input_data(self, params):
|
||||
"""优先 input_arg(JSON 字符串或文件路径),其次 -c 传入的 params 字典。"""
|
||||
from rpa_yisaiqi.contract_create_node import ContractCreateError, load_input_data
|
||||
raw = (self.input_arg or "").strip()
|
||||
if raw:
|
||||
return load_input_data(raw)
|
||||
if isinstance(params, dict) and params:
|
||||
nested = params.get("inputJson") or params.get("inputArg") or params.get("input")
|
||||
if nested:
|
||||
return load_input_data(str(nested))
|
||||
return dict(params)
|
||||
raise ContractCreateError("缺少业务入参:请通过 -i/--input 传入 JSON 字符串或文件路径,或通过 -c/--params 传入参数")
|
||||
|
||||
@staticmethod
|
||||
def _first(*values):
|
||||
for value in values:
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return ""
|
||||
|
||||
def Main(self, **params):
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260813000000,Title:合同建单,Note:读取入参并执行 CONTRACT_CREATE')
|
||||
import sys, os, json
|
||||
from pathlib import Path
|
||||
_CODES_DIR = Path(r'd:\RPA\AutoRpa\codes')
|
||||
if str(_CODES_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_CODES_DIR))
|
||||
from rpa_yisaiqi.contract_flow_steps import ContractFlowContext, ContractGoodsLine
|
||||
print("=" * 60)
|
||||
print("合同建单 CONTRACT_CREATE - 初始化")
|
||||
print("=" * 60)
|
||||
self._ctx = ContractFlowContext(
|
||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||
username="test002", password="1",
|
||||
supplier_name="测试供应商",
|
||||
order_date="2026-08-12",
|
||||
source_type="YYC", template_type="NON_FORMAT",
|
||||
template_name="非格式--采购协议",
|
||||
customer_code="CUST-001", customer_name="测试供应商",
|
||||
contract_category="采购协议", settlement_type="付款",
|
||||
contract_background="自动化测试合同起草", contract_nature="主合同",
|
||||
amount_currency="人民币", amount="50000.00",
|
||||
contract_end_date="2026-12-31",
|
||||
is_major_contract="否", is_prepayment="否",
|
||||
belong_year="2026", payment_method="账期支付",
|
||||
payment_days="30", contract_status="正式合同",
|
||||
contains_blood_products="否",
|
||||
submit_mode="dry_run",
|
||||
goods_lines=[
|
||||
ContractGoodsLine(
|
||||
operation_code="ADD", goods_code="YP-001",
|
||||
goods_name="测试药品A", approval_no="国药准字H00000001",
|
||||
spec="50mg*30片", unit="盒", quantity=10.0,
|
||||
),
|
||||
],
|
||||
merged_pdf_path=None,
|
||||
|
||||
from rpa_yisaiqi.contract_create_node import run_contract_create
|
||||
|
||||
data = self._resolve_input_data(params)
|
||||
state_center_url = self._first(
|
||||
data.get("stateCenterUrl"),
|
||||
data.get("stateCenterURL"),
|
||||
os.getenv("STATE_CENTER_URL"),
|
||||
params.get("stateCenterUrl"),
|
||||
"http://127.0.0.1:8000",
|
||||
)
|
||||
print(f"[OK] 上下文就绪: source={self._ctx.source_type}")
|
||||
print(f"[OK] 模板: {self._ctx.template_name}")
|
||||
print(f"[OK] 货品行数: {len(self._ctx.goods_lines)}")
|
||||
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200001,Title:插入代码块#1,Note:Selenium打开Chrome浏览器并导航到合同系统')
|
||||
from rpa_yisaiqi.apa_browser_adapter import ApaBrowserAdapter
|
||||
print("[S02] 启动 Chrome + 打开合同系统...")
|
||||
print(f" URL: {self._ctx.contract_url}")
|
||||
adapter = ApaBrowserAdapter(url=self._ctx.contract_url)
|
||||
adapter.open_url(self._ctx.contract_url)
|
||||
self._adapter = adapter
|
||||
print("[OK] Chrome 已打开并导航到合同系统")
|
||||
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200002,Title:插入代码块#1,Note:登录系统并进入合同起草页面')
|
||||
from rpa_yisaiqi.contract_flow_steps import login_contract_system, enter_contract_create
|
||||
adapter = self._adapter
|
||||
print("[S03] 登录合同系统...")
|
||||
login_contract_system(adapter, self._ctx)
|
||||
print("[OK] 登录完成")
|
||||
print("[S04] 进入合同起草...")
|
||||
enter_contract_create(adapter)
|
||||
print("[OK] 已进入合同起草页面")
|
||||
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200003,Title:插入代码块#1,Note:选择模板并填写签约方信息')
|
||||
from rpa_yisaiqi.contract_flow_steps import select_contract_template, fill_contract_signatory_info
|
||||
adapter = self._adapter
|
||||
print("[S05] 选择合同模板...")
|
||||
select_contract_template(adapter, self._ctx)
|
||||
print(f"[OK] 模板: {self._ctx.template_name}")
|
||||
print("[S06] 填写签约方信息...")
|
||||
fill_contract_signatory_info(adapter, self._ctx)
|
||||
print(f"[OK] 签约方: {self._ctx.customer_name}")
|
||||
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200004,Title:插入代码块#1,Note:填写合同类别、金额、日期')
|
||||
from rpa_yisaiqi.contract_flow_steps import fill_contract_basic_info, fill_contract_amount_and_dates
|
||||
adapter = self._adapter
|
||||
print("[S07] 填写基础表单...")
|
||||
fill_contract_basic_info(adapter, self._ctx)
|
||||
print(f"[OK] 类别={self._ctx.contract_category}, 结算={self._ctx.settlement_type}")
|
||||
print("[S08] 填写金额与日期...")
|
||||
fill_contract_amount_and_dates(adapter, self._ctx)
|
||||
print(f"[OK] 金额={self._ctx.amount}, 结束日期={self._ctx.contract_end_date}")
|
||||
|
||||
#插入代码块#1
|
||||
self.__logger.dlogs(job_no=self.job_no,logmsg='Flow:Main,StepNodeTag:20260812200005,Title:插入代码块#1,Note:填写货品明细、付款信息并提交')
|
||||
from rpa_yisaiqi.contract_flow_steps import (
|
||||
fill_contract_goods_lines, fill_contract_payment_info,
|
||||
upload_contract_document_if_needed, submit_contract_for_approval,
|
||||
read_contract_number
|
||||
agent_token = self._first(
|
||||
data.get("agentToken"),
|
||||
os.getenv("AGENT_TOKEN"),
|
||||
params.get("agentToken"),
|
||||
"dev-agent-token",
|
||||
)
|
||||
adapter = self._adapter
|
||||
print("[S09] 填写货品明细...")
|
||||
fill_contract_goods_lines(adapter, self._ctx)
|
||||
print(f"[OK] 货品: {len(self._ctx.goods_lines)} 行")
|
||||
print("[S10] 填写付款信息...")
|
||||
fill_contract_payment_info(adapter, self._ctx)
|
||||
print(f"[OK] 付款方式={self._ctx.payment_method}")
|
||||
if self._ctx.source_type == "YYC":
|
||||
print("[S11] 合并PDF [YYC专属]... (单PDF, 跳过合并)")
|
||||
print("[S12] 上传PDF附件 [YYC专属]... (dry_run 跳过)")
|
||||
print()
|
||||
print("[S13] 提交审批...")
|
||||
if not submit_contract_for_approval(adapter, self._ctx):
|
||||
print("[DRY-RUN] 演练模式,跳过实际提交")
|
||||
|
||||
print("=" * 60)
|
||||
print("合同建单流程演练完成")
|
||||
print("=" * 60)
|
||||
else:
|
||||
print("[S14] 读取合同编号...")
|
||||
contract_no = read_contract_number(adapter)
|
||||
print(f"[OK] 合同编号: {contract_no}")
|
||||
print("=" * 60)
|
||||
print(f"[COMPLETED] 合同创建成功: {contract_no}")
|
||||
print("合同建单 CONTRACT_CREATE - 开始")
|
||||
print("jobId=%s sourceType=%s submitMode=%s" % (
|
||||
data.get("jobId"), data.get("sourceType"), data.get("submitMode")))
|
||||
print("=" * 60)
|
||||
|
||||
result = run_contract_create(
|
||||
data,
|
||||
state_center_url=state_center_url,
|
||||
agent_token=agent_token,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ILog.begin_init()
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -71,10 +71,14 @@ class ApaBrowserAdapter:
|
|||
return self._driver.find_element(By.XPATH, xpath)
|
||||
except NoSuchElementException:
|
||||
# Fallback: find the label's "for" attribute
|
||||
xpath2 = f'//label[contains(text(),"{text_val}")]/@for'
|
||||
for_id = self._driver.find_element(By.XPATH, xpath2).get_attribute("for") if False else None
|
||||
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 属性
|
||||
|
|
@ -87,9 +91,9 @@ class ApaBrowserAdapter:
|
|||
text_val = selector[6:].strip('"').strip("'")
|
||||
return self._driver.find_element(By.CSS_SELECTOR, f'[title="{text_val}"]')
|
||||
|
||||
# 5. :contains() → 转 XPath
|
||||
# 5. :contains() → 转 XPath(支持 button:contains(文本) / a:contains("文本"))
|
||||
if ":contains(" in selector:
|
||||
m = re.match(r'(.+?):contains\("(.+?)"\)', selector)
|
||||
m = re.match(r'(.+?):contains\(["\']?(.+?)["\']?\)', selector)
|
||||
if m:
|
||||
tag_part, text_val = m.groups()
|
||||
xpath = f'//{tag_part}[contains(.,"{text_val}")]'
|
||||
|
|
@ -164,6 +168,21 @@ class ApaBrowserAdapter:
|
|||
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
|
||||
|
|
@ -176,6 +195,30 @@ class ApaBrowserAdapter:
|
|||
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 select_first(self, selectors: list, value: str) -> None:
|
||||
self._ensure_open()
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
|
@ -183,14 +226,59 @@ class ApaBrowserAdapter:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,52 +1,31 @@
|
|||
"""CONTRACT_CREATE node skeleton for YiSaiQi Windows integration.
|
||||
|
||||
This first version proves the state-center callback path and leaves the
|
||||
contract-system page automation behind a single replaceable runner function.
|
||||
"""
|
||||
"""CONTRACT_CREATE 节点编排:入参构建 + 状态中心回调 + Selenium 起草。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .apa_browser_adapter import ApaBrowserAdapter
|
||||
from .callback_client import StateCenterClient
|
||||
from .contract_draft import ContractDraft, load_contract_draft
|
||||
from .contract_draft import ContractDraft, contract_draft_from_dict, load_contract_draft
|
||||
from .contract_flow_steps import ContractFlowContext, run_contract_create_flow
|
||||
from .errors import (
|
||||
ContractCreateError,
|
||||
LoginFailed,
|
||||
PdfMergeUnsupported,
|
||||
SupplierContractDisabled,
|
||||
UnknownRpaError,
|
||||
)
|
||||
from .pdf_utils import ensure_merged_pdf
|
||||
from .yisaiqi_adapter_template import YiSaiQiBrowserAdapter
|
||||
|
||||
NODE_CODE = "CONTRACT_CREATE"
|
||||
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
|
||||
|
||||
|
||||
class ContractCreateError(RuntimeError):
|
||||
error_code = "UI_ELEMENT_NOT_FOUND"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
class LoginFailed(ContractCreateError):
|
||||
error_code = "SYS_LOGIN_FAIL"
|
||||
retryable = True
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
class SupplierContractDisabled(ContractCreateError):
|
||||
error_code = "BIZ_CONTRACT_DISABLED"
|
||||
retryable = False
|
||||
route_role = "PURCHASER"
|
||||
|
||||
|
||||
class PdfMergeUnsupported(ContractCreateError):
|
||||
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContractCreateInput:
|
||||
job_id: str
|
||||
|
|
@ -58,9 +37,12 @@ class ContractCreateInput:
|
|||
contract_url: str = CONTRACT_URL
|
||||
username: str = "test002"
|
||||
password: str = "1"
|
||||
open_browser: bool = False
|
||||
submit_mode: str = "submit"
|
||||
order_no_list: tuple[str, ...] = ()
|
||||
template_name: str = ""
|
||||
draft_path: str | None = None
|
||||
merged_pdf_output: str | None = None
|
||||
flow_context: ContractFlowContext | None = None
|
||||
|
||||
|
||||
ContractRunner = Callable[[ContractCreateInput], str]
|
||||
|
|
@ -76,42 +58,58 @@ def execute_contract_create(
|
|||
runner = contract_runner or create_contract_in_contract_system
|
||||
try:
|
||||
contract_no = runner(spec)
|
||||
complete_payload = build_complete_payload(spec, contract_no)
|
||||
return client.complete_node(spec.job_id, NODE_CODE, complete_payload)
|
||||
if spec.submit_mode == "dry_run":
|
||||
return {"status": "dry_run", "submitted": False}
|
||||
if not contract_no:
|
||||
raise ContractCreateError("submit 模式未读取到真实合同编号,无法 complete")
|
||||
return client.complete_node(spec.job_id, NODE_CODE, build_complete_payload(spec, contract_no))
|
||||
except ContractCreateError as exc:
|
||||
fail_payload = build_fail_payload(spec, exc)
|
||||
return client.fail_node(spec.job_id, NODE_CODE, fail_payload)
|
||||
return client.fail_node(spec.job_id, NODE_CODE, build_fail_payload(spec, exc))
|
||||
except Exception as exc:
|
||||
return client.fail_node(spec.job_id, NODE_CODE, build_fail_payload(spec, UnknownRpaError(str(exc))))
|
||||
|
||||
|
||||
def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
|
||||
"""Draft the contract in the contract system and return contract number."""
|
||||
if spec.open_browser:
|
||||
webbrowser.open(spec.contract_url)
|
||||
"""在合同系统中起草合同并返回合同编号。"""
|
||||
if spec.contract_system_no:
|
||||
return spec.contract_system_no
|
||||
|
||||
ctx = spec.flow_context
|
||||
if ctx is None:
|
||||
if not spec.draft_path:
|
||||
raise ContractCreateError("draft_path is required for real contract drafting")
|
||||
draft = load_contract_draft(spec.draft_path)
|
||||
ctx = build_flow_context(spec, draft)
|
||||
browser = YiSaiQiBrowserAdapter()
|
||||
try:
|
||||
raise ContractCreateError("缺少 flow_context 或 draft_path,无法起草合同")
|
||||
ctx = build_flow_context(spec, load_contract_draft(spec.draft_path))
|
||||
|
||||
browser = ApaBrowserAdapter(url=ctx.contract_url)
|
||||
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_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)
|
||||
return replace(spec, flow_context=ctx)
|
||||
|
||||
|
||||
def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> ContractFlowContext:
|
||||
merged_pdf_path = draft.merged_pdf_path
|
||||
if draft.source_type == "YYC":
|
||||
output_path = spec.merged_pdf_output or str(
|
||||
Path("runtime") / "contracts" / spec.job_id / "yyc-contract-merged.pdf"
|
||||
)
|
||||
try:
|
||||
merged_pdf_path = ensure_merged_pdf(draft.platform_pdf_paths, output_path)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise PdfMergeUnsupported(str(exc)) from exc
|
||||
merged_pdf_path = _resolve_merged_pdf(draft, spec)
|
||||
return ContractFlowContext(
|
||||
contract_url=spec.contract_url,
|
||||
username=spec.username,
|
||||
|
|
@ -140,9 +138,32 @@ def build_flow_context(spec: ContractCreateInput, draft: ContractDraft) -> Contr
|
|||
submit_mode=draft.submit_mode,
|
||||
goods_lines=draft.goods_lines,
|
||||
merged_pdf_path=merged_pdf_path,
|
||||
order_no_list=draft.order_no_list,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_merged_pdf(draft: ContractDraft, spec: ContractCreateInput) -> str | None:
|
||||
if draft.source_type != "YYC":
|
||||
return None
|
||||
|
||||
paths = [str(p).strip() for p in draft.platform_pdf_paths if str(p).strip()]
|
||||
if not paths:
|
||||
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_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[str, Any]:
|
||||
return {
|
||||
"attemptNo": spec.attempt_no,
|
||||
|
|
@ -153,8 +174,10 @@ def build_complete_payload(spec: ContractCreateInput, contract_no: str) -> dict[
|
|||
"supplierName": spec.supplier_name,
|
||||
"contractDate": spec.order_date,
|
||||
"sourceType": spec.source_type,
|
||||
"contractUrl": spec.contract_url,
|
||||
"uploadRule": "merge_pdf_before_upload" if spec.source_type == "YYC" else "skip_contract_upload",
|
||||
"orderNoList": list(spec.order_no_list),
|
||||
"templateName": spec.template_name,
|
||||
"pdfMerged": spec.source_type == "YYC",
|
||||
"submitted": True,
|
||||
},
|
||||
"artifacts": [],
|
||||
}
|
||||
|
|
@ -171,20 +194,108 @@ def build_fail_payload(spec: ContractCreateInput, exc: ContractCreateError) -> d
|
|||
}
|
||||
|
||||
|
||||
def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
|
||||
missing = []
|
||||
for key, label in (
|
||||
("jobId", "jobId"),
|
||||
("supplierName", "supplierName"),
|
||||
("orderDate", "orderDate"),
|
||||
("sourceType", "sourceType"),
|
||||
("customerCode", "customerCode"),
|
||||
):
|
||||
if not _str(data.get(key)):
|
||||
missing.append(label)
|
||||
|
||||
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:
|
||||
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:
|
||||
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("--token", default=os.getenv("AGENT_TOKEN", "dev-agent-token"))
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--supplier-name", required=True)
|
||||
parser.add_argument("--order-date", required=True)
|
||||
parser.add_argument("--input-json", default=os.getenv("CONTRACT_INPUT_JSON"))
|
||||
parser.add_argument("--job-id")
|
||||
parser.add_argument("--supplier-name")
|
||||
parser.add_argument("--order-date")
|
||||
parser.add_argument("--source-type", choices=("YYC", "XYY", "OFFLINE"), default="YYC")
|
||||
parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
||||
parser.add_argument("--attempt-no", type=int, default=1)
|
||||
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
||||
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
|
||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||
parser.add_argument("--open-browser", action="store_true")
|
||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||
return parser.parse_args()
|
||||
|
|
@ -192,6 +303,13 @@ def parse_args() -> argparse.Namespace:
|
|||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
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,
|
||||
|
|
@ -202,15 +320,35 @@ def main() -> int:
|
|||
contract_url=args.contract_url,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
open_browser=args.open_browser,
|
||||
draft_path=args.draft_path,
|
||||
merged_pdf_output=args.merged_pdf_output,
|
||||
)
|
||||
client = StateCenterClient(args.state_center_url, args.token)
|
||||
|
||||
result = execute_contract_create(spec, client)
|
||||
print(result)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
# 保持旧导入名可用(errors 模块为新的统一异常来源)
|
||||
__all__ = [
|
||||
"ContractCreateError",
|
||||
"LoginFailed",
|
||||
"SupplierContractDisabled",
|
||||
"PdfMergeUnsupported",
|
||||
"UnknownRpaError",
|
||||
"ContractCreateInput",
|
||||
"execute_contract_create",
|
||||
"create_contract_in_contract_system",
|
||||
"build_spec_from_data",
|
||||
"build_flow_context",
|
||||
"build_complete_payload",
|
||||
"build_fail_payload",
|
||||
"run_contract_create",
|
||||
"load_input_data",
|
||||
"NODE_CODE",
|
||||
"CONTRACT_URL",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ContractDraft:
|
|||
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||
platform_pdf_paths: list[str] = field(default_factory=list)
|
||||
merged_pdf_path: str | None = None
|
||||
order_no_list: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_contract_draft(path: str | Path) -> ContractDraft:
|
||||
|
|
@ -52,7 +53,9 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
|||
approval_no=str(item.get("approvalNo", "")),
|
||||
spec=str(item.get("spec", "")),
|
||||
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", [])
|
||||
]
|
||||
|
|
@ -82,4 +85,5 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
|
|||
goods_lines=goods_lines,
|
||||
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],
|
||||
merged_pdf_path=data.get("mergedPdfPath"),
|
||||
order_no_list=[str(x) for x in data.get("orderNoList", [])],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,18 +5,30 @@ node or one script block behind a flowchart node.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from .errors import (
|
||||
ContractCreateError,
|
||||
LoginFailed,
|
||||
PdfMergeUnsupported,
|
||||
SupplierContractDisabled,
|
||||
)
|
||||
|
||||
|
||||
class BrowserAdapter(Protocol):
|
||||
def open_url(self, url: str) -> None: ...
|
||||
def fill_first(self, selectors: list[str], value: str) -> None: ...
|
||||
def click_first(self, selectors: list[str]) -> None: ...
|
||||
def double_click_first(self, selectors: list[str]) -> None: ...
|
||||
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: ...
|
||||
def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: ...
|
||||
def element_exists(self, selectors: list[str]) -> bool: ...
|
||||
def select_first(self, selectors: list[str], value: str) -> None: ...
|
||||
def upload_file_first(self, selectors: list[str], file_path: str) -> None: ...
|
||||
def read_first(self, selectors: list[str], value_name: str) -> str: ...
|
||||
def read_first(self, selectors: list[str], value_name: str = "") -> str: ...
|
||||
def read_body_text(self) -> str: ...
|
||||
def screenshot(self, name: str) -> str: ...
|
||||
|
||||
|
||||
|
|
@ -29,6 +41,8 @@ class ContractGoodsLine:
|
|||
spec: str
|
||||
unit: str
|
||||
quantity: float
|
||||
mdm_code: str = ""
|
||||
unit_price: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -60,6 +74,7 @@ class ContractFlowContext:
|
|||
submit_mode: str = "submit"
|
||||
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
|
||||
merged_pdf_path: str | None = None
|
||||
order_no_list: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) -> str:
|
||||
|
|
@ -117,14 +132,54 @@ F = {
|
|||
"payment_days": 'input[name="extendDataFormInfo.value(fd_pay_time_limit)"]',
|
||||
"contract_status": 'input[name="extendDataFormInfo.value(fd_suconstatus)"]',
|
||||
"contains_blood": 'input[name="extendDataFormInfo.value(fd_3d1ce3953f1162)"]',
|
||||
"goods_code": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_opcode)"]',
|
||||
"goods_name": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsname)"]',
|
||||
"goods_mdm": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_mdmCode)"]',
|
||||
"goods_approval": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_registdocno)"]',
|
||||
"goods_spec": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodstype)"]',
|
||||
"goods_unit": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsunit)"]',
|
||||
"goods_qty": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_goodsqty)"]',
|
||||
"goods_price": 'input[name="extendDataFormInfo.value(fd_detail1.0.fd_unitprice)"]',
|
||||
}
|
||||
|
||||
# 货品明细行的字段后缀;行索引从 0 开始(fd_detail1.0.* 为第 1 行)
|
||||
GOODS_FIELD_SUFFIXES = {
|
||||
"goods_code": "fd_opcode",
|
||||
"goods_name": "fd_goodsname",
|
||||
"goods_mdm": "fd_mdmCode",
|
||||
"goods_approval": "fd_registdocno",
|
||||
"goods_spec": "fd_goodstype",
|
||||
"goods_unit": "fd_goodsunit",
|
||||
"goods_qty": "fd_goodsqty",
|
||||
"goods_price": "fd_unitprice",
|
||||
}
|
||||
|
||||
ADD_ROW_SELECTORS = [
|
||||
"text=添加行",
|
||||
"text=新增明细",
|
||||
"text=新增行",
|
||||
"text=新增",
|
||||
"button:contains(添加行)",
|
||||
]
|
||||
|
||||
# 客商选择窗口(双击客户名称后弹出)选择器。若真实页面改版,仅需调整此处。
|
||||
CUSTOMER_PICKER = {
|
||||
"search_input": [
|
||||
'input[placeholder*="客商"]',
|
||||
'input[placeholder*="编码"]',
|
||||
'input[placeholder*="搜索"]',
|
||||
'input[name*="search"]',
|
||||
'input[class*="search"]',
|
||||
],
|
||||
"search_button": [
|
||||
"text=搜索",
|
||||
"text=查询",
|
||||
"button:contains(搜索)",
|
||||
"a:contains(搜索)",
|
||||
],
|
||||
"result_rows": [
|
||||
"//table//tr[contains(.,'{code}')]",
|
||||
"//*[contains(@class,'list')]//*[contains(text(),'{code}')]",
|
||||
"text={code}",
|
||||
],
|
||||
"confirm_button": [
|
||||
"text=确定",
|
||||
"text=确认",
|
||||
"button:contains(确定)",
|
||||
"button:contains(确认)",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -133,10 +188,14 @@ def open_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> N
|
|||
|
||||
|
||||
def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
try:
|
||||
browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||||
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
||||
browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]'])
|
||||
browser.wait_for_text("合同", timeout_seconds=20)
|
||||
except Exception as exc:
|
||||
browser.screenshot("contract-login-failed")
|
||||
raise LoginFailed(f"合同系统登录失败:{exc}") from exc
|
||||
|
||||
|
||||
def enter_contract_create(browser: BrowserAdapter) -> None:
|
||||
|
|
@ -153,19 +212,63 @@ def select_contract_template(browser: BrowserAdapter, ctx: ContractFlowContext)
|
|||
|
||||
|
||||
def fill_contract_signatory_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
customer_name = ctx.customer_name or ctx.supplier_name
|
||||
if ctx.customer_code:
|
||||
expected_name = (ctx.customer_name or ctx.supplier_name).strip()
|
||||
if not ctx.customer_code:
|
||||
raise SupplierContractDisabled("客商操作码(customerCode)为空,无法加载客商")
|
||||
|
||||
# 双击客户名称字段打开客商选择窗口,按编码搜索并带回客商
|
||||
browser.double_click_first([F["customer_name"]])
|
||||
_pick_customer_from_dialog(browser, ctx.customer_code)
|
||||
loaded_name = _wait_customer_name(browser, timeout=8)
|
||||
|
||||
# 兜底:双击未弹出选择窗时,直接填客商操作码触发自动加载
|
||||
if not loaded_name:
|
||||
browser.fill_first([F["customer_code"]], ctx.customer_code)
|
||||
if customer_name:
|
||||
browser.fill_first([F["customer_name"]], customer_name)
|
||||
loaded_name = _wait_customer_name(browser, timeout=8)
|
||||
|
||||
if not loaded_name:
|
||||
browser.screenshot("contract-customer-load-failed")
|
||||
raise SupplierContractDisabled(
|
||||
f"客商加载失败,请确认输入的客商操作码是否有误:{ctx.customer_code}"
|
||||
)
|
||||
|
||||
if expected_name and loaded_name != expected_name:
|
||||
browser.screenshot("contract-customer-name-mismatch")
|
||||
raise SupplierContractDisabled(
|
||||
f"客商名称校验不通过:期望「{expected_name}」,实际「{loaded_name}」"
|
||||
)
|
||||
|
||||
browser.screenshot("contract-signatory-info-filled")
|
||||
|
||||
|
||||
def _wait_customer_name(browser: BrowserAdapter, timeout: int) -> str:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
body = browser.read_body_text()
|
||||
if "自动加载客商失败" in body or "客商操作码是否有误" in body:
|
||||
return ""
|
||||
name = _try_read(browser, [F["customer_name"]])
|
||||
if name and name not in ("==请选择==", "请选择"):
|
||||
return name
|
||||
time.sleep(0.5)
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_customer_from_dialog(browser: BrowserAdapter, code: str) -> None:
|
||||
browser.wait_for_element(CUSTOMER_PICKER["search_input"], timeout_seconds=10)
|
||||
browser.fill_first(CUSTOMER_PICKER["search_input"], code)
|
||||
browser.click_first(CUSTOMER_PICKER["search_button"])
|
||||
time.sleep(1)
|
||||
rows = [sel.format(code=code) for sel in CUSTOMER_PICKER["result_rows"]]
|
||||
browser.click_first(rows)
|
||||
browser.click_first(CUSTOMER_PICKER["confirm_button"])
|
||||
|
||||
|
||||
def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
if ctx.contract_category:
|
||||
browser.fill_first([F["contract_category"]], ctx.contract_category)
|
||||
if ctx.settlement_type:
|
||||
browser.select_first([F["settlement_type"]], ctx.settlement_type)
|
||||
_select_settlement_type(browser, ctx.settlement_type)
|
||||
if ctx.contract_background:
|
||||
browser.fill_first([F["contract_background"]], ctx.contract_background)
|
||||
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
|
||||
|
|
@ -174,6 +277,18 @@ def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext)
|
|||
browser.screenshot("contract-basic-info-filled")
|
||||
|
||||
|
||||
def _select_settlement_type(browser: BrowserAdapter, value: str) -> None:
|
||||
browser.select_first([F["settlement_type"]], value)
|
||||
selected = _try_read(browser, [F["settlement_type"]])
|
||||
if not selected or selected in ("==请选择==", "请选择"):
|
||||
# LUI 自定义下拉兜底:点击展开后按可见文本选择
|
||||
browser.click_first([F["settlement_type"]])
|
||||
browser.click_first([f"//option[contains(text(),'{value}')]", f"text={value}"])
|
||||
selected = _try_read(browser, [F["settlement_type"]])
|
||||
if not selected or selected in ("==请选择==", "请选择"):
|
||||
raise ContractCreateError(f"结算方式下拉未选中,仍停留在「==请选择==」,期望「{value}」")
|
||||
|
||||
|
||||
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
if ctx.amount:
|
||||
browser.fill_first([F["amount"]], ctx.amount)
|
||||
|
|
@ -185,21 +300,33 @@ def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowCon
|
|||
|
||||
|
||||
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
for index, line in enumerate(ctx.goods_lines, start=1):
|
||||
if index > 1:
|
||||
browser.click_first(["text=添加行", "text=新增明细", "text=新增行"])
|
||||
browser.fill_first([F["goods_code"]], line.goods_code)
|
||||
browser.fill_first([F["goods_name"]], line.goods_name)
|
||||
for index, line in enumerate(ctx.goods_lines):
|
||||
row = index # 第 1 行 row=0,第 2 行 row=1,依此类推
|
||||
if index > 0:
|
||||
browser.click_first(ADD_ROW_SELECTORS)
|
||||
browser.wait_for_element([_goods_selector("goods_code", row)], timeout_seconds=15)
|
||||
|
||||
browser.fill_first([_goods_selector("goods_code", row)], line.goods_code)
|
||||
browser.fill_first([_goods_selector("goods_name", row)], line.goods_name)
|
||||
if line.mdm_code:
|
||||
browser.fill_first([_goods_selector("goods_mdm", row)], line.mdm_code)
|
||||
if line.approval_no:
|
||||
browser.fill_first([F["goods_approval"]], line.approval_no)
|
||||
browser.fill_first([_goods_selector("goods_approval", row)], line.approval_no)
|
||||
if line.spec:
|
||||
browser.fill_first([F["goods_spec"]], line.spec)
|
||||
browser.fill_first([_goods_selector("goods_spec", row)], line.spec)
|
||||
if line.unit:
|
||||
browser.fill_first([F["goods_unit"]], line.unit)
|
||||
browser.fill_first([F["goods_qty"]], str(line.quantity))
|
||||
browser.fill_first([_goods_selector("goods_unit", row)], line.unit)
|
||||
browser.fill_first([_goods_selector("goods_qty", row)], str(line.quantity))
|
||||
if line.unit_price:
|
||||
browser.fill_first([_goods_selector("goods_price", row)], str(line.unit_price))
|
||||
browser.screenshot("contract-goods-lines-filled")
|
||||
|
||||
|
||||
def _goods_selector(field_key: str, row_index: int) -> str:
|
||||
suffix = GOODS_FIELD_SUFFIXES[field_key]
|
||||
return f'input[name="extendDataFormInfo.value(fd_detail1.{row_index}.{suffix})"]'
|
||||
|
||||
|
||||
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
browser.fill_first([F["is_major_contract"]], ctx.is_major_contract)
|
||||
browser.fill_first([F["is_prepayment"]], ctx.is_prepayment)
|
||||
|
|
@ -218,9 +345,14 @@ def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext
|
|||
|
||||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
if ctx.source_type != "YYC":
|
||||
# XYY / OFFLINE:跳过合同证文上传
|
||||
return
|
||||
if ctx.submit_mode == "dry_run":
|
||||
# dry_run 只跑到提交前,不真正上传附件
|
||||
browser.screenshot("contract-pdf-upload-skipped")
|
||||
return
|
||||
if not ctx.merged_pdf_path:
|
||||
raise ValueError("YYC contract creation requires merged_pdf_path before upload")
|
||||
raise PdfMergeUnsupported("YYC 合同起草缺少合并后的 PDF 路径,无法上传")
|
||||
browser.upload_file_first(['input[type="file"]', "label=合同证文上传"], ctx.merged_pdf_path)
|
||||
browser.screenshot("contract-pdf-uploaded")
|
||||
|
||||
|
|
@ -242,5 +374,14 @@ def read_contract_number(browser: BrowserAdapter) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _try_read(browser: BrowserAdapter, selectors: list[str]) -> str:
|
||||
if not browser.element_exists(selectors):
|
||||
return ""
|
||||
try:
|
||||
return (browser.read_first(selectors, "") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def contract_version_label(ctx: ContractFlowContext) -> str:
|
||||
return "非格式合同" if ctx.template_type == "NON_FORMAT" else "格式合同"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
"""CONTRACT_CREATE 领域异常及状态中心错误码映射。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ContractCreateError(RuntimeError):
|
||||
"""页面元素找不到 / 页面改版(含入参缺失,均不可自动重试)。"""
|
||||
|
||||
error_code = "UI_ELEMENT_NOT_FOUND"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
class LoginFailed(ContractCreateError):
|
||||
"""合同系统登录失败。"""
|
||||
|
||||
error_code = "SYS_LOGIN_FAIL"
|
||||
retryable = True
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
class SupplierContractDisabled(ContractCreateError):
|
||||
"""供应商/客商被禁或失效(含客商加载失败)。"""
|
||||
|
||||
error_code = "BIZ_CONTRACT_DISABLED"
|
||||
retryable = False
|
||||
route_role = "PURCHASER"
|
||||
|
||||
|
||||
class PdfMergeUnsupported(ContractCreateError):
|
||||
"""PDF 合并失败或不受支持。"""
|
||||
|
||||
error_code = "CAP_PDF_MERGE_UNSUPPORTED"
|
||||
retryable = False
|
||||
route_role = "OPS"
|
||||
|
||||
|
||||
class UnknownRpaError(ContractCreateError):
|
||||
"""未知异常。"""
|
||||
|
||||
error_code = "RPA_UNKNOWN_ERROR"
|
||||
retryable = True
|
||||
route_role = "OPS"
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"supplierName": "重庆亿昊药业",
|
||||
"supplierName": "重庆亿昊药业有限公司",
|
||||
"orderDate": "2026-08-11",
|
||||
"sourceType": "YYC",
|
||||
"templateType": "NON_FORMAT",
|
||||
"templateName": "非格式--采购协议",
|
||||
"customerCode": "CUST-YYC-001",
|
||||
"customerName": "重庆亿昊药业",
|
||||
"customerCode": "DW0100046361",
|
||||
"customerName": "重庆亿昊药业有限公司",
|
||||
"contractCategory": "采购协议",
|
||||
"settlementType": "付款",
|
||||
"contractBackground": "按医药城订单自动起草采购协议",
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
pypdf==5.1.0
|
||||
selenium
|
||||
webdriver-manager
|
||||
requests
|
||||
|
|
|
|||
|
|
@ -20,10 +20,26 @@ class YiSaiQiBrowserAdapter:
|
|||
"""Flowchart node: 按候选定位器依次查找,找到后点击."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi click activity: {selectors=}")
|
||||
|
||||
def double_click_first(self, selectors: list[str]) -> None:
|
||||
"""Flowchart node: 双击元素(客商选择窗口等)."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi double-click activity: {selectors=}")
|
||||
|
||||
def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None:
|
||||
"""Flowchart node: 等待页面出现指定文本."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi wait-text activity: {text=} {timeout_seconds=}")
|
||||
|
||||
def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None:
|
||||
"""Flowchart node: 等待元素出现."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi wait-element activity: {selectors=}")
|
||||
|
||||
def element_exists(self, selectors: list[str]) -> bool:
|
||||
"""Flowchart node: 判断元素是否存在."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi element-exists activity: {selectors=}")
|
||||
|
||||
def read_body_text(self) -> str:
|
||||
"""Flowchart node: 读取页面 body 文本."""
|
||||
raise NotImplementedError("Map to YiSaiQi read-body-text activity")
|
||||
|
||||
def select_first(self, selectors: list[str], value: str) -> None:
|
||||
"""Flowchart node: 下拉框选择."""
|
||||
raise NotImplementedError(f"Map to YiSaiQi select activity: {selectors=} {value=}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue