feat: 合同页按 MDM 搜货并按 fdId 读号
合单缺货品 MDM 直接失败;页面按 MDM 搜货,同品各占一行。 提交前读 fdId,回我的合同按文档/标题+客户编码匹配。 无平台正文时挂占位 PDF;Chrome 用户目录按适配器实例拆开。 默认登录账号改为 10007228。
This commit is contained in:
parent
3ccda7d6c2
commit
bf0294a12d
|
|
@ -110,7 +110,7 @@
|
|||
| 步 | 函数 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| S02 打开 | `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
|
||||
| S03 登录 | `login_contract_system` | `test002` / `1` |
|
||||
| S03 登录 | `login_contract_system` | `10007228` / `1` |
|
||||
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
||||
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
||||
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
## 测试账号
|
||||
|
||||
地址:`http://contract-test.gzmpc.com:8080/`
|
||||
账号:`test002 / 1`
|
||||
账号:`10007228 / 1`
|
||||
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
||||
|
||||
## 页面步骤
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ class ApaBrowserAdapter:
|
|||
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())
|
||||
# 每次适配器单独目录:同进程第二组、上次 detach 留下的 Chrome 都会锁住同一 profile
|
||||
profile_dir = profile_base / f"{os.getpid()}-{id(self)}"
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
profile_dir = str(profile_dir)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class ContractCreateInput:
|
|||
contract_system_no: str | None = None
|
||||
attempt_no: int = 1
|
||||
contract_url: str = CONTRACT_URL
|
||||
username: str = "test002"
|
||||
username: str = "10007228"
|
||||
password: str = "1"
|
||||
submit_mode: str = "submit"
|
||||
order_no_list: tuple[str, ...] = ()
|
||||
|
|
@ -107,7 +107,7 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
|
|||
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"),
|
||||
username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "10007228"),
|
||||
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
|
||||
submit_mode=draft.submit_mode,
|
||||
order_no_list=tuple(draft.order_no_list),
|
||||
|
|
@ -295,7 +295,7 @@ def parse_args() -> argparse.Namespace:
|
|||
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("--username", default=os.getenv("CONTRACT_USERNAME", "10007228"))
|
||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||
|
|
|
|||
|
|
@ -113,17 +113,23 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
|
|||
fill_contract_goods_lines(browser, ctx)
|
||||
fill_contract_payment_info(browser, ctx)
|
||||
upload_contract_document_if_needed(browser, ctx)
|
||||
fd_id = _read_document_id(browser)
|
||||
if not submit_contract_for_approval(browser, ctx):
|
||||
if ctx.submit_mode == "dry_run":
|
||||
_ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True)
|
||||
return ""
|
||||
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
|
||||
return ""
|
||||
contract_no = read_contract_number(browser, ctx)
|
||||
fd_id = fd_id or _read_document_id(browser)
|
||||
contract_no = read_contract_number(browser, ctx, fd_id=fd_id)
|
||||
_ACCEPT.note(
|
||||
"S13",
|
||||
"读合同号",
|
||||
{"contractNo": contract_no or "(none)", "via": "我的合同/申请时间/第一行"},
|
||||
{
|
||||
"contractNo": contract_no or "(none)",
|
||||
"via": "我的合同查询",
|
||||
"fdId": fd_id or "(none)",
|
||||
},
|
||||
ok=_usable_contract_no(contract_no),
|
||||
)
|
||||
return contract_no
|
||||
|
|
@ -242,7 +248,6 @@ ADD_ROW_SELECTORS = [
|
|||
"text=添加行",
|
||||
"text=新增明细",
|
||||
"text=新增行",
|
||||
"text=新增",
|
||||
"button:contains(添加行)",
|
||||
]
|
||||
|
||||
|
|
@ -309,6 +314,10 @@ def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) ->
|
|||
evidence_shot(browser, "contract-already-logged-in")
|
||||
_ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True)
|
||||
return
|
||||
browser.wait_for_element(
|
||||
['input[name="j_username"]', 'input[placeholder="用户名"]'],
|
||||
timeout_seconds=15,
|
||||
)
|
||||
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"]'])
|
||||
|
|
@ -581,21 +590,37 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext)
|
|||
evidence_shot(browser, "contract-goods-lines-filled")
|
||||
_ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True)
|
||||
return
|
||||
used: list[int] = []
|
||||
assigned: list[int] = []
|
||||
for line in ctx.goods_lines:
|
||||
_pick_goods_from_dialog(browser, line)
|
||||
_fill_goods_required_qty_price(browser, line)
|
||||
row = _find_goods_row(browser, line, skip=used)
|
||||
if row < 0:
|
||||
same = _existing_used_match(browser, line, used)
|
||||
if same >= 0:
|
||||
row = _append_same_goods_row(browser, same)
|
||||
else:
|
||||
if used and not _empty_goods_row_indexes(browser):
|
||||
_add_goods_row(browser)
|
||||
row = _pick_goods_from_dialog(browser, line, skip=used)
|
||||
_fill_goods_required_qty_price(browser, line, row)
|
||||
used.append(row)
|
||||
assigned.append(row)
|
||||
_delete_empty_goods_rows(browser)
|
||||
evidence_shot(browser, "contract-goods-lines-filled")
|
||||
for index, line in enumerate(ctx.goods_lines):
|
||||
row = _find_goods_row(browser, line)
|
||||
code = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
|
||||
for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)):
|
||||
opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
|
||||
mdm = _try_read(browser, [_goods_selector("goods_mdm", row)]) if row >= 0 else ""
|
||||
qty = _try_read(browser, [_goods_selector("goods_qty", row)]) if row >= 0 else ""
|
||||
price = _try_read(browser, [_goods_selector("goods_price", row)]) if row >= 0 else ""
|
||||
if line.mdm_code:
|
||||
code_pair = ("goodsMdm", mdm or opcode, line.mdm_code)
|
||||
else:
|
||||
code_pair = ("goodsCode", opcode, line.goods_code)
|
||||
_ACCEPT.check(
|
||||
"S09",
|
||||
f"货品明细#{index + 1}",
|
||||
[
|
||||
("goodsCode", code, line.goods_code),
|
||||
code_pair,
|
||||
("qty", qty, line.quantity),
|
||||
("price", price, line.unit_price or "1.00"),
|
||||
],
|
||||
|
|
@ -604,11 +629,33 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext)
|
|||
)
|
||||
|
||||
|
||||
def _pick_goods_from_dialog(browser: BrowserAdapter, line: ContractGoodsLine) -> None:
|
||||
keyword = (line.goods_code or line.goods_name or "").strip()
|
||||
if not keyword:
|
||||
raise NodeError("货品缺少 goodsCode/goodsName,无法在「选择商品」中搜索")
|
||||
def _goods_search_keyword(line: ContractGoodsLine) -> str:
|
||||
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
|
||||
|
||||
|
||||
def _pick_goods_from_dialog(
|
||||
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||||
) -> int:
|
||||
keyword = _goods_search_keyword(line)
|
||||
if not keyword:
|
||||
raise NodeError("货品缺少 goodsMdmCode/goodsCode,无法在「选择商品」中搜索")
|
||||
|
||||
_select_goods_in_picker(browser, line, keyword)
|
||||
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||||
if row >= 0:
|
||||
return row
|
||||
_add_goods_row(browser)
|
||||
_select_goods_in_picker(browser, line, keyword)
|
||||
row = _wait_unused_goods_row(browser, line, skip, timeout=12)
|
||||
if row >= 0:
|
||||
return row
|
||||
_scroll_to_goods_table(browser)
|
||||
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
|
||||
dump = _goods_table_dump(browser)
|
||||
raise NodeError(f"选择商品后未在明细表找到新的一行「{keyword}」 dump={dump}")
|
||||
|
||||
|
||||
def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, keyword: str) -> None:
|
||||
browser.click_first(GOODS_PICKER["trigger"])
|
||||
browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15)
|
||||
browser.switch_to_frame(GOODS_PICKER["frame"])
|
||||
|
|
@ -631,15 +678,6 @@ def _pick_goods_from_dialog(browser: BrowserAdapter, line: ContractGoodsLine) ->
|
|||
raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc
|
||||
browser.switch_to_default_content()
|
||||
_click_dialog_ok(browser)
|
||||
deadline = time.time() + 12
|
||||
while time.time() < deadline:
|
||||
if _find_goods_row(browser, line) >= 0:
|
||||
return
|
||||
_click_dialog_ok(browser)
|
||||
time.sleep(0.6)
|
||||
_scroll_to_goods_table(browser)
|
||||
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
|
||||
raise NodeError(f"选择商品后未在明细表找到「{keyword}」")
|
||||
|
||||
|
||||
def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None:
|
||||
|
|
@ -755,11 +793,55 @@ def _click_dialog_ok(browser: BrowserAdapter) -> None:
|
|||
browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"])
|
||||
|
||||
|
||||
def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int:
|
||||
def _as_index_list(value) -> list[int]:
|
||||
if value is None or value is False:
|
||||
return []
|
||||
if isinstance(value, bool):
|
||||
return []
|
||||
if isinstance(value, (int, float)):
|
||||
return [int(value)]
|
||||
if isinstance(value, str):
|
||||
parts = [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
|
||||
out: list[int] = []
|
||||
for part in parts:
|
||||
try:
|
||||
out.append(int(float(part)))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
try:
|
||||
seq = list(value)
|
||||
except TypeError:
|
||||
return []
|
||||
out: list[int] = []
|
||||
for item in seq:
|
||||
try:
|
||||
out.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _next_unused_goods_row(matches: list[int], skip: list[int] | tuple[int, ...] = ()) -> int:
|
||||
used = {int(i) for i in skip}
|
||||
for row in matches:
|
||||
if row not in used:
|
||||
return row
|
||||
return -1
|
||||
|
||||
|
||||
def _find_goods_row(
|
||||
browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = ()
|
||||
) -> int:
|
||||
row = browser.execute_script(
|
||||
"""
|
||||
const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean);
|
||||
const skip = {};
|
||||
String(arguments[2] || '').split(',').forEach((part) => {
|
||||
if (part !== '') skip[Number(part)] = true;
|
||||
});
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (skip[i]) continue;
|
||||
const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno'];
|
||||
const values = fields.map((suffix) => {
|
||||
const el = document.querySelector(
|
||||
|
|
@ -777,8 +859,9 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int:
|
|||
}
|
||||
return -1;
|
||||
""",
|
||||
line.goods_code,
|
||||
line.mdm_code or line.goods_code,
|
||||
line.goods_name,
|
||||
",".join(str(int(i)) for i in skip),
|
||||
)
|
||||
try:
|
||||
return int(row)
|
||||
|
|
@ -786,8 +869,211 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int:
|
|||
return -1
|
||||
|
||||
|
||||
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine) -> None:
|
||||
row = _find_goods_row(browser, line)
|
||||
def _goods_table_dump(browser: BrowserAdapter) -> str:
|
||||
dump = browser.execute_script(
|
||||
"""
|
||||
const out = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mdm = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||
);
|
||||
const qty = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsqty)"]'
|
||||
);
|
||||
const price = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_unitprice)"]'
|
||||
);
|
||||
if (!mdm && !qty && !price) continue;
|
||||
out.push(
|
||||
String(i) + ':' + (mdm ? String(mdm.value || '') : '') +
|
||||
':' + (qty ? String(qty.value || '') : '') +
|
||||
':' + (price ? String(price.value || '') : '')
|
||||
);
|
||||
}
|
||||
return out.join('|');
|
||||
"""
|
||||
)
|
||||
return str(dump or "")
|
||||
|
||||
|
||||
def _wait_unused_goods_row(
|
||||
browser: BrowserAdapter,
|
||||
line: ContractGoodsLine,
|
||||
skip: list[int] | tuple[int, ...],
|
||||
timeout: float,
|
||||
) -> int:
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
row = _find_goods_row(browser, line, skip=skip)
|
||||
if row >= 0:
|
||||
return row
|
||||
if time.time() >= deadline:
|
||||
return -1
|
||||
_click_dialog_ok(browser)
|
||||
time.sleep(0.6)
|
||||
|
||||
|
||||
def _empty_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||||
rows = browser.execute_script(
|
||||
"""
|
||||
const found = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const code = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
|
||||
);
|
||||
const name = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]'
|
||||
);
|
||||
const mdm = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||
);
|
||||
if (!code && !name && !mdm) continue;
|
||||
const empty = !(code && String(code.value || '').trim())
|
||||
&& !(name && String(name.value || '').trim())
|
||||
&& !(mdm && String(mdm.value || '').trim());
|
||||
if (empty) found.push(i);
|
||||
}
|
||||
return found.join(',');
|
||||
"""
|
||||
)
|
||||
return _as_index_list(rows)
|
||||
|
||||
|
||||
def _occupied_goods_row_indexes(browser: BrowserAdapter) -> list[int]:
|
||||
rows = browser.execute_script(
|
||||
"""
|
||||
const found = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const mdm = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]'
|
||||
);
|
||||
const code = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'
|
||||
);
|
||||
const filled = (mdm && String(mdm.value || '').trim())
|
||||
|| (code && String(code.value || '').trim());
|
||||
if (filled) found.push(i);
|
||||
}
|
||||
return found.join(',');
|
||||
"""
|
||||
)
|
||||
return _as_index_list(rows)
|
||||
|
||||
|
||||
def _add_goods_row(browser: BrowserAdapter) -> None:
|
||||
before = _occupied_goods_row_indexes(browser)
|
||||
_scroll_to_goods_table(browser)
|
||||
result = browser.execute_script(
|
||||
"""
|
||||
const wanted = ['添加行', '新增明细', '新增行'];
|
||||
const nodes = Array.from(document.querySelectorAll('a, div, span, button, input'));
|
||||
for (const el of nodes) {
|
||||
const text = (el.value || el.textContent || '').replace(/\\s+/g, '').trim();
|
||||
if (!wanted.includes(text)) continue;
|
||||
if (el.children && el.children.length > 2) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) continue;
|
||||
el.click();
|
||||
return {ok: true, via: text};
|
||||
}
|
||||
const tables = Array.from(document.querySelectorAll('table[id]')).map((t) => t.id);
|
||||
if (typeof DocList_AddRow === 'function') {
|
||||
const hit = tables.find((id) =>
|
||||
id.toLowerCase().indexOf('detail') >= 0 || id.indexOf('DocList') >= 0
|
||||
);
|
||||
if (hit) {
|
||||
DocList_AddRow(hit);
|
||||
return {ok: true, via: 'DocList:' + hit};
|
||||
}
|
||||
DocList_AddRow();
|
||||
return {ok: true, via: 'DocList()'};
|
||||
}
|
||||
return {ok: false, tables: tables.slice(0, 8).join(',')};
|
||||
"""
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
if _empty_goods_row_indexes(browser):
|
||||
return
|
||||
if len(_occupied_goods_row_indexes(browser)) > len(before):
|
||||
return
|
||||
time.sleep(0.3)
|
||||
if isinstance(result, dict) and not result.get("ok"):
|
||||
raise NodeError(f"同品种需要新的明细行,未找到「添加行」 tables={result.get('tables')}")
|
||||
try:
|
||||
browser.click_native(ADD_ROW_SELECTORS)
|
||||
except Exception:
|
||||
browser.click_first(ADD_ROW_SELECTORS)
|
||||
time.sleep(0.5)
|
||||
if _empty_goods_row_indexes(browser):
|
||||
return
|
||||
dump = _goods_table_dump(browser)
|
||||
raise NodeError(f"同品种需要新的明细行,点击「添加行」后没有空行 dump={dump}")
|
||||
|
||||
|
||||
def _existing_used_match(
|
||||
browser: BrowserAdapter, line: ContractGoodsLine, used: list[int]
|
||||
) -> int:
|
||||
key = (line.mdm_code or line.goods_code or "").strip()
|
||||
name = (line.goods_name or "").strip()
|
||||
for row in used:
|
||||
blob = " ".join(
|
||||
value
|
||||
for value in (
|
||||
_try_read(browser, [_goods_selector("goods_mdm", row)]),
|
||||
_try_read(browser, [_goods_selector("goods_code", row)]),
|
||||
_try_read(browser, [_goods_selector("goods_name", row)]),
|
||||
)
|
||||
if value
|
||||
)
|
||||
if key and key in blob:
|
||||
return row
|
||||
if name and name in blob:
|
||||
return row
|
||||
return -1
|
||||
|
||||
|
||||
def _append_same_goods_row(browser: BrowserAdapter, src_row: int) -> int:
|
||||
empties = _empty_goods_row_indexes(browser)
|
||||
if not empties:
|
||||
_add_goods_row(browser)
|
||||
empties = _empty_goods_row_indexes(browser)
|
||||
if not empties:
|
||||
dump = _goods_table_dump(browser)
|
||||
raise NodeError(f"同品种需要新的明细行,添加行后仍没有空行 dump={dump}")
|
||||
dest = empties[0]
|
||||
_clone_goods_identity(browser, src_row, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int) -> None:
|
||||
browser.execute_script(
|
||||
"""
|
||||
const src = Number(arguments[0]);
|
||||
const dest = Number(arguments[1]);
|
||||
const fields = [
|
||||
'fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno',
|
||||
'fd_goodstype', 'fd_goodsunit'
|
||||
];
|
||||
for (const field of fields) {
|
||||
const from = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + src + '.' + field + ')"]'
|
||||
);
|
||||
const to = document.querySelector(
|
||||
'input[name="extendDataFormInfo.value(fd_detail1.' + dest + '.' + field + ')"]'
|
||||
);
|
||||
if (!from || !to) continue;
|
||||
to.value = from.value;
|
||||
to.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
to.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
""",
|
||||
src_row,
|
||||
dest_row,
|
||||
)
|
||||
|
||||
|
||||
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None:
|
||||
if row < 0:
|
||||
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
|
||||
qty = str(line.quantity if line.quantity else "")
|
||||
|
|
@ -882,19 +1168,25 @@ def _fill_concession_defaults(browser: BrowserAdapter) -> None:
|
|||
)
|
||||
|
||||
|
||||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
def _resolve_contract_body_path(ctx: ContractFlowContext) -> str:
|
||||
path = (ctx.contract_document_path or "").strip()
|
||||
if not path and ctx.source_type == "YYC":
|
||||
path = (ctx.merged_pdf_path or "").strip()
|
||||
if not path:
|
||||
if path:
|
||||
return path
|
||||
if ctx.submit_mode == "dry_run":
|
||||
return ""
|
||||
from pdf_utils import ensure_placeholder_contract_pdf
|
||||
|
||||
return ensure_placeholder_contract_pdf()
|
||||
|
||||
|
||||
def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
|
||||
path = _resolve_contract_body_path(ctx)
|
||||
if not path:
|
||||
evidence_shot(browser, "contract-pdf-upload-skipped")
|
||||
_ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True)
|
||||
return
|
||||
if ctx.source_type == "YYC":
|
||||
raise PdfMergeUnsupported("缺少合同正文/平台合同文件,无法上传")
|
||||
_ACCEPT.note("S11", "上传正文", {"skipped": ctx.source_type or "no_file"}, ok=True)
|
||||
return
|
||||
try:
|
||||
_upload_contract_body(browser, path)
|
||||
except Exception as exc:
|
||||
|
|
@ -1070,36 +1362,62 @@ def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowConte
|
|||
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
|
||||
return False
|
||||
_close_blocking_dialogs(browser)
|
||||
fd_id = _read_document_id(browser)
|
||||
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号仍从我的合同查。
|
||||
try:
|
||||
browser.click_native(
|
||||
[
|
||||
"//*[normalize-space()='暂存']/following::*[normalize-space()='提交'][1]",
|
||||
"text=提交",
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
_click_top_submit(browser)
|
||||
evidence_shot(browser, "contract-after-submit-click")
|
||||
_confirm_submit_dialogs(browser)
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
if _submit_succeeded(browser):
|
||||
evidence_shot(browser, "contract-submitted")
|
||||
print("提交成功,回我的合同取编号。", flush=True)
|
||||
fd_id = fd_id or _read_document_id(browser)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "yes"}, ok=True)
|
||||
return True
|
||||
_confirm_submit_dialogs(browser)
|
||||
time.sleep(0.5)
|
||||
_click_toolbar_button(browser, "暂存")
|
||||
except Exception as exc:
|
||||
print(f"暂存未点到:{exc}", flush=True)
|
||||
saved = _wait_after_save_or_submit(browser, timeout_seconds=15)
|
||||
blocked = _submit_validation_error(browser)
|
||||
if blocked:
|
||||
evidence_shot(browser, "contract-submitted", failed=True)
|
||||
print("提交后仍在起草页,浏览器保持打开,请手工检查。", flush=True)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "no", "page": "draft"}, ok=False, raise_error=False)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked, "at": "暂存"}, ok=False, raise_error=False)
|
||||
return False
|
||||
if not saved:
|
||||
print("暂存后仍在表单页,继续点提交。", flush=True)
|
||||
try:
|
||||
_click_toolbar_button(browser, "提交")
|
||||
except Exception as exc:
|
||||
evidence_shot(browser, "contract-submitted", failed=True)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120]}, ok=False, raise_error=False)
|
||||
return False
|
||||
evidence_shot(browser, "contract-after-submit-click")
|
||||
submitted = _wait_after_save_or_submit(browser, timeout_seconds=20)
|
||||
blocked = _submit_validation_error(browser)
|
||||
if blocked:
|
||||
evidence_shot(browser, "contract-submitted", failed=True)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked}, ok=False, raise_error=False)
|
||||
return False
|
||||
print("提交已点,回我的合同查询合同号。", flush=True)
|
||||
_ACCEPT.note("S12", "提交", {"submitted": "yes", "via": "暂存后提交", "leftForm": "yes" if submitted else "no"}, ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def _submit_error_from_body(body: str) -> str:
|
||||
text = body or ""
|
||||
if "该文档不存在" in text:
|
||||
return "该文档不存在"
|
||||
if "操作失败" in text and "您的请求已提交" in text:
|
||||
return "操作失败"
|
||||
return ""
|
||||
|
||||
|
||||
def _wait_after_save_or_submit(browser: BrowserAdapter, timeout_seconds: float) -> bool:
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline:
|
||||
if _submit_validation_error(browser):
|
||||
return False
|
||||
_confirm_submit_dialogs(browser)
|
||||
if _submit_succeeded(browser):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return not bool(_submit_validation_error(browser)) and _submit_succeeded(browser)
|
||||
|
||||
|
||||
def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
||||
body = browser.read_body_text() or ""
|
||||
if _submit_error_from_body(body):
|
||||
return False
|
||||
if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")):
|
||||
return True
|
||||
url = ""
|
||||
|
|
@ -1110,6 +1428,28 @@ def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
|||
return "method=add" not in url and "kmAgreementApply" in url
|
||||
|
||||
|
||||
def _submit_validation_error(browser: BrowserAdapter) -> str:
|
||||
body = browser.read_body_text() or ""
|
||||
hit = _submit_error_from_body(body)
|
||||
if hit:
|
||||
return hit
|
||||
text = browser.execute_script(
|
||||
"""
|
||||
const roots = Array.from(document.querySelectorAll(
|
||||
'.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame'
|
||||
));
|
||||
return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n').slice(0, 400);
|
||||
"""
|
||||
)
|
||||
blob = str(text or "").strip()
|
||||
if not blob:
|
||||
return ""
|
||||
for mark in ("不能为空", "请填写", "校验失败", "提交失败", "必填项未填", "该文档不存在", "操作失败"):
|
||||
if mark in blob:
|
||||
return blob[:120]
|
||||
return ""
|
||||
|
||||
|
||||
def _read_document_id(browser: BrowserAdapter) -> str:
|
||||
value = browser.execute_script(
|
||||
"""
|
||||
|
|
@ -1138,36 +1478,52 @@ def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None:
|
|||
time.sleep(1.5)
|
||||
|
||||
|
||||
def _click_top_submit(browser: BrowserAdapter) -> None:
|
||||
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> None:
|
||||
clicked = browser.execute_script(
|
||||
"""
|
||||
const toolbar = document.querySelector('.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]');
|
||||
const root = toolbar || document;
|
||||
const hits = Array.from(root.querySelectorAll('div, a, button, span')).filter((el) => {
|
||||
return (el.textContent || '').trim() === '提交' && el.children.length === 0;
|
||||
const label = String(arguments[0] || '');
|
||||
function visible(el) {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width > 2 && box.height > 2;
|
||||
}
|
||||
function clickIn(win) {
|
||||
const toolbar = win.document.querySelector(
|
||||
'.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]'
|
||||
);
|
||||
const roots = [toolbar, win.document].filter(Boolean);
|
||||
for (const root of roots) {
|
||||
const hits = Array.from(root.querySelectorAll('div, a, button, span, li')).filter((el) => {
|
||||
return (el.textContent || '').replace(/\\s+/g, '').trim() === label && visible(el);
|
||||
});
|
||||
if (!hits.length) return {ok: false};
|
||||
const save = Array.from(document.querySelectorAll('div, a, span')).find((el) => {
|
||||
return (el.textContent || '').trim() === '暂存' && el.children.length === 0;
|
||||
});
|
||||
const target = (save && hits.find((el) => {
|
||||
const a = save.getBoundingClientRect();
|
||||
const b = el.getBoundingClientRect();
|
||||
return Math.abs(a.top - b.top) < 40;
|
||||
})) || hits[0];
|
||||
target.click();
|
||||
return {ok: true};
|
||||
"""
|
||||
hits.sort((a, b) => a.children.length - b.children.length);
|
||||
if (hits.length) {
|
||||
hits[0].click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < win.frames.length; i++) {
|
||||
try { if (clickIn(win.frames[i])) return true; } catch (e) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return {ok: clickIn(window), label: label};
|
||||
""",
|
||||
label,
|
||||
)
|
||||
print(f"toolbar {label} js={clicked}", flush=True)
|
||||
if isinstance(clicked, dict) and clicked.get("ok"):
|
||||
return
|
||||
browser.click_first(["text=提交", "text=提交送审", "text=送审"])
|
||||
browser.click_first([f"text={label}", f"button:contains({label})"])
|
||||
|
||||
|
||||
def _click_top_submit(browser: BrowserAdapter) -> None:
|
||||
_click_toolbar_button(browser, "提交")
|
||||
|
||||
|
||||
def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
|
||||
browser.execute_script(
|
||||
"""
|
||||
const labels = ['确定', '确认', '是', '提交', 'OK'];
|
||||
const labels = ['确定', '确认', '是', 'OK'];
|
||||
const nodes = Array.from(document.querySelectorAll('div, a, button, span, input'));
|
||||
for (const label of labels) {
|
||||
const hit = nodes.find((el) => {
|
||||
|
|
@ -1182,7 +1538,9 @@ def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
|
|||
time.sleep(0.8)
|
||||
|
||||
|
||||
def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | None = None) -> str:
|
||||
def read_contract_number(
|
||||
browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = ""
|
||||
) -> str:
|
||||
if ctx is None:
|
||||
ctx = ContractFlowContext(
|
||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||
|
|
@ -1192,7 +1550,7 @@ def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | Non
|
|||
order_date="",
|
||||
source_type="",
|
||||
)
|
||||
return _read_contract_number_from_my_list(browser, ctx)
|
||||
return _read_contract_number_from_my_list(browser, ctx, fd_id=fd_id)
|
||||
|
||||
|
||||
def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
|
||||
|
|
@ -1200,43 +1558,51 @@ def _read_contract_number_on_page(browser: BrowserAdapter) -> str:
|
|||
browser.switch_to_default_content()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
value = (browser.read_first(
|
||||
[
|
||||
'input[name="fdNumber"]',
|
||||
'input[name="contractNo"]',
|
||||
'input[name="contractSystemNo"]',
|
||||
"label=合同编号",
|
||||
],
|
||||
"contract_number",
|
||||
) or "").strip()
|
||||
if _usable_contract_no(value):
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
scanned = browser.execute_script(
|
||||
"""
|
||||
const skip = ['提交时自动生成', '自动生成', '==请选择=='];
|
||||
const inputs = document.querySelectorAll(
|
||||
'input[name="fdNumber"], input[name="contractNo"], input[name*="fdNumber"]'
|
||||
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
|
||||
function fromDoc(doc) {
|
||||
const inputs = doc.querySelectorAll(
|
||||
'input[name="fdNumber"], input[name="contractNo"], input[name="contractSystemNo"], input[name*="fdNumber"]'
|
||||
);
|
||||
for (const el of inputs) {
|
||||
const v = (el.value || '').trim();
|
||||
if (v && skip.indexOf(v) < 0) return v;
|
||||
}
|
||||
const nodes = Array.from(document.querySelectorAll('td, th, span, div, label, em, b'));
|
||||
const nodes = Array.from(doc.querySelectorAll('td, th, span, div, label, em, b'));
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim();
|
||||
if (label !== '合同编号' && label !== '合同编号:') continue;
|
||||
const next = nodes[i + 1];
|
||||
const n = next ? ((next.innerText || next.value || '').replace(/\\s+/g, ' ').trim()) : '';
|
||||
if (label !== '合同编号' && label !== '合同编号:' && label !== '合同编号:') continue;
|
||||
const box = nodes[i].parentElement || nodes[i];
|
||||
const typed = box.querySelector('input, textarea');
|
||||
const n = typed
|
||||
? String(typed.value || '').trim()
|
||||
: ((nodes[i + 1] && (nodes[i + 1].innerText || nodes[i + 1].value || '')) || '')
|
||||
.replace(/\\s+/g, ' ').trim();
|
||||
if (n && skip.indexOf(n) < 0) return n;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function walk(win) {
|
||||
try {
|
||||
const no = fromDoc(win.document);
|
||||
if (no) return no;
|
||||
} catch (e) {}
|
||||
for (let i = 0; i < win.frames.length; i++) {
|
||||
try {
|
||||
const no = walk(win.frames[i]);
|
||||
if (no) return no;
|
||||
} catch (e) {}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return walk(window);
|
||||
"""
|
||||
)
|
||||
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
|
||||
return scanned.strip()
|
||||
if scanned:
|
||||
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
|
||||
body = browser.read_body_text() or ""
|
||||
for line in body.splitlines():
|
||||
text = line.strip()
|
||||
|
|
@ -1423,24 +1789,258 @@ def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _pick_contract_row(
|
||||
rows: list[dict],
|
||||
*,
|
||||
fd_id: str = "",
|
||||
title: str = "",
|
||||
customer_code: str = "",
|
||||
) -> str:
|
||||
fd_id = (fd_id or "").strip()
|
||||
title = (title or "").strip()
|
||||
customer_code = (customer_code or "").strip()
|
||||
if fd_id:
|
||||
for row in rows:
|
||||
href = str(row.get("href") or "")
|
||||
no = str(row.get("no") or "").strip()
|
||||
if fd_id in href and _usable_contract_no(no):
|
||||
return no
|
||||
if title and customer_code:
|
||||
for row in rows:
|
||||
row_title = str(row.get("title") or "")
|
||||
row_cust = str(row.get("customer") or "")
|
||||
no = str(row.get("no") or "").strip()
|
||||
if title in row_title and customer_code in row_cust and _usable_contract_no(no):
|
||||
return no
|
||||
if title:
|
||||
for row in rows:
|
||||
no = str(row.get("no") or "").strip()
|
||||
if title in str(row.get("title") or "") and _usable_contract_no(no):
|
||||
return no
|
||||
return ""
|
||||
|
||||
|
||||
def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str) -> None:
|
||||
title = (title or "").strip()
|
||||
customer_code = (customer_code or "").strip()
|
||||
if not title and not customer_code:
|
||||
return
|
||||
browser.execute_script(
|
||||
"""
|
||||
const title = String(arguments[0] || '');
|
||||
const customer = String(arguments[1] || '');
|
||||
function setValue(el, value) {
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
return true;
|
||||
}
|
||||
function fillNearLabel(doc, label, value) {
|
||||
if (!value) return false;
|
||||
const nodes = Array.from(doc.querySelectorAll('td, th, span, label, div, em'));
|
||||
for (const node of nodes) {
|
||||
const text = (node.innerText || '').replace(/\\s+/g, '').trim();
|
||||
if (text !== label && text !== label + ':' && text !== label + ':') continue;
|
||||
const box = node.parentElement || node;
|
||||
const input = box.querySelector('input[type="text"], input:not([type]), input[type="search"]');
|
||||
if (setValue(input, value)) return true;
|
||||
const next = node.nextElementSibling;
|
||||
if (next && setValue(next.querySelector('input'), value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function clickSearch(doc) {
|
||||
const labels = ['搜索', '查询'];
|
||||
const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input'));
|
||||
for (const label of labels) {
|
||||
const hit = nodes.find((el) => {
|
||||
const text = (el.textContent || el.value || '').trim();
|
||||
return text === label && (!el.children || el.children.length === 0);
|
||||
});
|
||||
if (hit) { hit.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function walk(win) {
|
||||
try {
|
||||
fillNearLabel(win.document, '合同标题', title)
|
||||
|| fillNearLabel(win.document, '合同(协议)全称', title);
|
||||
fillNearLabel(win.document, '客户编码', customer);
|
||||
clickSearch(win.document);
|
||||
} catch (e) {}
|
||||
for (let i = 0; i < win.frames.length; i++) {
|
||||
try { walk(win.frames[i]); } catch (e) {}
|
||||
}
|
||||
}
|
||||
walk(window);
|
||||
return true;
|
||||
""",
|
||||
title,
|
||||
customer_code,
|
||||
)
|
||||
_wait_text_in_frames(browser, "合同编号", timeout_seconds=10)
|
||||
|
||||
|
||||
def _scrape_my_contract_rows(browser: BrowserAdapter) -> list[dict]:
|
||||
raw = browser.execute_script(
|
||||
"""
|
||||
function scrapeTable(doc) {
|
||||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||
let header = null;
|
||||
const found = [];
|
||||
for (const tr of rows) {
|
||||
const cells = Array.from(tr.querySelectorAll('th, td')).map((c) =>
|
||||
(c.innerText || '').replace(/\\s+/g, ' ').trim()
|
||||
);
|
||||
if (!header && cells.indexOf('合同编号') >= 0) {
|
||||
header = {};
|
||||
cells.forEach((h, i) => { if (h && header[h] === undefined) header[h] = i; });
|
||||
continue;
|
||||
}
|
||||
if (!header) continue;
|
||||
const no = (cells[header['合同编号']] || '').trim();
|
||||
const title = (
|
||||
cells[header['合同标题']]
|
||||
|| cells[header['合同(协议)全称']]
|
||||
|| cells[header['合同名称']]
|
||||
|| cells[header['主题']]
|
||||
|| cells[header['标题']]
|
||||
|| ''
|
||||
).trim();
|
||||
const customer = (
|
||||
cells[header['客户编码']] || cells[header['客户名称']] || cells[header['客商编码']] || ''
|
||||
).trim();
|
||||
const link = tr.querySelector('a[href*="fdId"], a[href*="fdid"]');
|
||||
const box = tr.querySelector('input[name="List_Selected"]');
|
||||
const href = link
|
||||
? String(link.getAttribute('href') || '')
|
||||
: (box ? String(box.value || '') : (tr.innerHTML || ''));
|
||||
if (!no && !href) continue;
|
||||
found.push({no: no, title: title, customer: customer, href: href});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function scrapeLinks(doc) {
|
||||
const found = [];
|
||||
const links = Array.from(doc.querySelectorAll('a[href*="fdId"], a[href*="fdid"]'));
|
||||
for (const a of links) {
|
||||
const href = String(a.getAttribute('href') || '');
|
||||
const tr = a.closest('tr') || a.parentElement;
|
||||
const blob = ((tr && tr.innerText) || a.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||
const hit = blob.match(/HT[0-9A-Za-z-]+/) || blob.match(/\\b\\d{10,}\\b/);
|
||||
found.push({
|
||||
no: hit ? hit[0] : '',
|
||||
title: blob,
|
||||
customer: blob,
|
||||
href: href
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function walk(win) {
|
||||
let all = [];
|
||||
try { all = all.concat(scrapeTable(win.document)); } catch (e) {}
|
||||
try { all = all.concat(scrapeLinks(win.document)); } catch (e) {}
|
||||
for (let i = 0; i < win.frames.length; i++) {
|
||||
try { all = all.concat(walk(win.frames[i])); } catch (e) {}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
return walk(window);
|
||||
"""
|
||||
)
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
rows: list[dict] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"no": str(item.get("no") or "").strip(),
|
||||
"title": str(item.get("title") or "").strip(),
|
||||
"customer": str(item.get("customer") or "").strip(),
|
||||
"href": str(item.get("href") or "").strip(),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _row_has_fd_id(rows: list[dict], fd_id: str) -> bool:
|
||||
token = (fd_id or "").strip()
|
||||
if not token:
|
||||
return False
|
||||
return any(token in str(row.get("href") or "") for row in rows)
|
||||
|
||||
|
||||
def _read_number_by_fd_id(browser: BrowserAdapter, fd_id: str) -> str:
|
||||
doc_id = (fd_id or "").strip()
|
||||
if not doc_id:
|
||||
return ""
|
||||
_open_submitted_view(browser, doc_id)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
body = browser.read_body_text() or ""
|
||||
if "该文档不存在" in body or "操作失败" in body:
|
||||
print(f"S13 详情不存在 fdId={doc_id}", flush=True)
|
||||
return ""
|
||||
no = _read_contract_number_on_page(browser)
|
||||
if no:
|
||||
print("从合同详情读到合同编号:", no, flush=True)
|
||||
return no
|
||||
time.sleep(0.8)
|
||||
return ""
|
||||
|
||||
|
||||
def _read_contract_number_from_my_list(
|
||||
browser: BrowserAdapter,
|
||||
ctx: ContractFlowContext,
|
||||
timeout_seconds: int = 15,
|
||||
timeout_seconds: int = 25,
|
||||
fd_id: str = "",
|
||||
) -> str:
|
||||
print("S13:刷新主界面 → 我的合同 → 申请时间 → 第一行最新合同号", flush=True)
|
||||
title = _expected_title(ctx)
|
||||
customer = (ctx.customer_code or "").strip()
|
||||
print(
|
||||
f"S13:回我的合同查询 fdId={fd_id or '-'} title={title} customer={customer}",
|
||||
flush=True,
|
||||
)
|
||||
_open_my_contracts(browser, ctx)
|
||||
_sort_my_contracts_by_apply_time(browser)
|
||||
deadline = time.time() + timeout_seconds
|
||||
last = ""
|
||||
searched = False
|
||||
while time.time() < deadline:
|
||||
last = _scrape_latest_my_contract_number(browser)
|
||||
if last:
|
||||
print("从我的合同读到最新合同编号:", last, flush=True)
|
||||
return last
|
||||
time.sleep(0.3)
|
||||
print("我的合同列表未读到合同编号", flush=True)
|
||||
return last
|
||||
rows = _scrape_my_contract_rows(browser)
|
||||
print(f"S13 列表行数={len(rows)}", flush=True)
|
||||
for row in rows[:6]:
|
||||
print(
|
||||
"S13 row"
|
||||
f" no={row.get('no')!r}"
|
||||
f" title={str(row.get('title') or '')[:60]!r}"
|
||||
f" customer={str(row.get('customer') or '')[:30]!r}"
|
||||
f" href={str(row.get('href') or '')[:90]!r}",
|
||||
flush=True,
|
||||
)
|
||||
no = _pick_contract_row(
|
||||
rows,
|
||||
fd_id=fd_id,
|
||||
title=title,
|
||||
customer_code=customer,
|
||||
)
|
||||
if no:
|
||||
print("从我的合同读到合同编号:", no, flush=True)
|
||||
return no
|
||||
if fd_id and _row_has_fd_id(rows, fd_id):
|
||||
no = _read_number_by_fd_id(browser, fd_id)
|
||||
if no:
|
||||
return no
|
||||
_open_my_contracts(browser, ctx)
|
||||
if not searched:
|
||||
_search_my_contracts(browser, title, customer)
|
||||
searched = True
|
||||
time.sleep(1)
|
||||
print("我的合同列表未匹配到合同编号", flush=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _usable_contract_no(value: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
%PDF-1.3
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer (pypdf)
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 1
|
||||
/Kids [ 4 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000054 00000 n
|
||||
0000000113 00000 n
|
||||
0000000162 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
256
|
||||
%%EOF
|
||||
|
|
@ -31,6 +31,9 @@ def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None:
|
|||
return "缺少供应商MDM码"
|
||||
if not (rows[0].supplier_mdm_code or "").strip():
|
||||
return "缺少供应商MDM码"
|
||||
for row in rows:
|
||||
if not (row.goods_mdm_code or "").strip():
|
||||
return "缺少货品MDM码"
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -58,14 +61,14 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
|||
)
|
||||
goods = [
|
||||
ContractGoodsLine(
|
||||
operation_code=row.goods_id,
|
||||
goods_code=row.goods_id,
|
||||
operation_code=(row.goods_mdm_code or "").strip(),
|
||||
goods_code=(row.goods_mdm_code or "").strip(),
|
||||
goods_name=row.goods_name,
|
||||
approval_no=row.reg_no,
|
||||
spec=row.spec,
|
||||
unit=row.unit,
|
||||
quantity=float(row.qty),
|
||||
mdm_code=row.goods_mdm_code or "",
|
||||
mdm_code=(row.goods_mdm_code or "").strip(),
|
||||
unit_price=money4(row.unit_price),
|
||||
)
|
||||
for row in rows
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
"""合同 PDF 合并。"""
|
||||
"""合同 PDF 合并、提交占位正文。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf"
|
||||
|
||||
|
||||
def ensure_placeholder_contract_pdf() -> str:
|
||||
"""提交必须挂合同正文;没有平台文件时用这一份占位 PDF。"""
|
||||
path = _PLACEHOLDER_PDF
|
||||
if path.is_file() and path.stat().st_size > 64:
|
||||
return str(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
from pypdf import PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
with path.open("wb") as file:
|
||||
writer.write(file)
|
||||
return str(path)
|
||||
|
||||
|
||||
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
|
||||
"""Return one PDF path, merging multiple source PDFs when needed.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
from pathlib import Path
|
||||
|
||||
from contract_flow_steps import (
|
||||
ContractFlowContext,
|
||||
begin_date_for,
|
||||
check_customer_diligence,
|
||||
sign_date_for,
|
||||
_as_index_list,
|
||||
_next_unused_goods_row,
|
||||
_pick_contract_row,
|
||||
_resolve_contract_body_path,
|
||||
_submit_error_from_body,
|
||||
)
|
||||
from errors import DiligenceError
|
||||
import pytest
|
||||
|
|
@ -48,3 +55,82 @@ def test_diligence_raises_when_page_says_not_done():
|
|||
|
||||
def test_diligence_passes_when_text_absent():
|
||||
check_customer_diligence(_FakeBrowser("客户已尽调"), _ctx())
|
||||
|
||||
|
||||
def test_same_product_keeps_own_row():
|
||||
assert _next_unused_goods_row([0, 1, 2], []) == 0
|
||||
assert _next_unused_goods_row([0, 1, 2], [0]) == 1
|
||||
assert _next_unused_goods_row([0, 1, 2], [0, 1]) == 2
|
||||
assert _next_unused_goods_row([0], [0]) == -1
|
||||
assert _next_unused_goods_row([], [0]) == -1
|
||||
|
||||
|
||||
def test_goods_row_indexes_from_script_string():
|
||||
assert _as_index_list("0,1,2") == [0, 1, 2]
|
||||
assert _as_index_list("") == []
|
||||
assert _as_index_list(None) == []
|
||||
assert _as_index_list([0, 1]) == [0, 1]
|
||||
|
||||
|
||||
def test_pick_contract_row_fd_id_beats_latest():
|
||||
rows = [
|
||||
{"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"},
|
||||
{
|
||||
"no": "HT-OURS",
|
||||
"title": "重庆亿昊药业有限公司采购协议20261002",
|
||||
"customer": "DW0100046361",
|
||||
"href": "kmAgreementApply.do?method=view&fdId=ours",
|
||||
},
|
||||
]
|
||||
assert (
|
||||
_pick_contract_row(
|
||||
rows,
|
||||
fd_id="ours",
|
||||
title="重庆亿昊药业有限公司采购协议20261002",
|
||||
customer_code="DW0100046361",
|
||||
)
|
||||
== "HT-OURS"
|
||||
)
|
||||
|
||||
|
||||
def test_pick_contract_row_title_and_customer():
|
||||
rows = [
|
||||
{"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"},
|
||||
{
|
||||
"no": "HT-OURS",
|
||||
"title": "重庆亿昊药业有限公司采购协议20261002",
|
||||
"customer": "DW0100046361",
|
||||
"href": "fdId=ours",
|
||||
},
|
||||
]
|
||||
assert (
|
||||
_pick_contract_row(
|
||||
rows,
|
||||
title="重庆亿昊药业有限公司采购协议20261002",
|
||||
customer_code="DW0100046361",
|
||||
)
|
||||
== "HT-OURS"
|
||||
)
|
||||
|
||||
|
||||
def test_pick_contract_row_does_not_take_first_unmatched():
|
||||
rows = [{"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"}]
|
||||
assert _pick_contract_row(rows, title="重庆亿昊", customer_code="DW0100046361") == ""
|
||||
|
||||
|
||||
def test_submit_error_from_doc_missing_page():
|
||||
body = "您的请求已提交,系统返回如下信息:\n操作失败!\n该文档不存在"
|
||||
assert _submit_error_from_body(body) == "该文档不存在"
|
||||
assert _submit_error_from_body("客户编码 提交") == ""
|
||||
|
||||
|
||||
def test_submit_without_file_uses_placeholder_body():
|
||||
ctx = _ctx(submit_mode="submit", source_type="")
|
||||
path = _resolve_contract_body_path(ctx)
|
||||
assert Path(path).is_file()
|
||||
assert Path(path).stat().st_size > 64
|
||||
|
||||
|
||||
def test_dry_run_without_file_skips_body():
|
||||
ctx = _ctx(submit_mode="dry_run", source_type="")
|
||||
assert _resolve_contract_body_path(ctx) == ""
|
||||
|
|
|
|||
|
|
@ -79,8 +79,9 @@ def test_group_by_supplier_keeps_fid_order_and_first_header():
|
|||
assert draft.source_type == ""
|
||||
assert draft.is_prepayment == "否"
|
||||
assert len(draft.goods_lines) == 2
|
||||
assert draft.goods_lines[0].goods_code == "G0001"
|
||||
assert draft.goods_lines[0].operation_code == "G0001"
|
||||
assert draft.goods_lines[0].goods_code == "QX0100167583"
|
||||
assert draft.goods_lines[0].operation_code == "QX0100167583"
|
||||
assert draft.goods_lines[0].mdm_code == "QX0100167583"
|
||||
assert draft.goods_lines[0].unit_price == "100.0000"
|
||||
assert draft.belong_year == "2026"
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ def test_group_by_supplier_keeps_fid_order_and_first_header():
|
|||
def test_missing_mdm_reason():
|
||||
rows = [_row(supplier_mdm_code="")]
|
||||
assert missing_mdm_reason(rows) == "缺少供应商MDM码"
|
||||
assert missing_mdm_reason([_row(goods_mdm_code="")]) == "缺少货品MDM码"
|
||||
assert missing_mdm_reason([_row()]) is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,14 @@ def test_job_merges_same_supplier_and_writes_table4():
|
|||
|
||||
run_batch(repo, runner=runner)
|
||||
assert len(calls) == 1
|
||||
assert [line.goods_code for line in calls[0].goods_lines] == ["G0001", "G0002"]
|
||||
assert [line.goods_code for line in calls[0].goods_lines] == [
|
||||
"QX0100167583",
|
||||
"QX0100167583",
|
||||
]
|
||||
assert [line.mdm_code for line in calls[0].goods_lines] == [
|
||||
"QX0100167583",
|
||||
"QX0100167583",
|
||||
]
|
||||
assert calls[0].customer_code == "DW0100046361"
|
||||
assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502]
|
||||
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue