AutoRpa/rpa_contract/contract_goods.py

606 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 合同 S09:货品明细。弹窗按操作码搜,找不到则本单失败。
#
# 同品种多行时先克隆身份再填数量单价,不把两行压进同一格。
from __future__ import annotations
import time
from accept import evidence_shot
from errors import NodeError
from contract_common import (
ADD_ROW_SELECTORS,
GOODS_FIELD_SUFFIXES,
GOODS_PICKER,
BrowserAdapter,
ContractFlowContext,
ContractGoodsLine,
_ACCEPT,
_try_read,
)
# 填货品明细(S09)。弹窗按操作码搜,找不到则本单失败。
def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
if not ctx.goods_lines:
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:
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)
used = []
assigned = []
for line in ctx.goods_lines:
row = _find_goods_row(browser, line, skip=used)
assigned.append(row)
if row >= 0:
used.append(row)
evidence_shot(browser, "contract-goods-lines-filled")
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}",
[
code_pair,
("qty", qty, line.quantity),
("price", price, line.unit_price or "1.00"),
],
browser=browser,
screenshot_on_fail="contract-goods-pick-failed",
)
# 货品弹窗搜索关键字:MDM、操作码、名称按这个顺序取第一个非空。
def _goods_search_keyword(line: ContractGoodsLine) -> str:
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
# 判断关键字是不是操作码形态(FQX/QX 开头且含数字)。
def _looks_like_goods_opcode(keyword: str) -> bool:
compact = (keyword or "").replace(" ", "").upper()
return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact)
# 把商品选择弹窗当前可见行收成短文本,失败时写进报错。
def _picker_rows_dump(browser: BrowserAdapter) -> str:
dump = browser.execute_script(
"""
return Array.from(document.querySelectorAll('tr')).map((row) => {
const text = (row.innerText || '').replace(/\\s+/g, ' ').trim();
const box = row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
if (!box || !text) return '';
return text.slice(0, 80);
}).filter(Boolean).slice(0, 8).join('|');
"""
)
return str(dump or "")
# 打开选择商品弹窗搜一行货,返回明细表里新占用的行号。
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}")
# 在选择商品 iframe 里搜关键字并勾中对应行,然后点确定。
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"])
try:
_search_goods_in_dialog(browser, keyword)
if not _dialog_row_has_keyword(browser, keyword):
_search_goods_in_dialog(browser, keyword)
picked = _select_dialog_row_by_keyword(browser, keyword)
if not picked and line.goods_name and not _looks_like_goods_opcode(keyword):
_search_goods_in_dialog(browser, line.goods_name.strip())
picked = _select_dialog_row_by_keyword(browser, keyword)
if not picked:
picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip())
if not picked:
dump = _picker_rows_dump(browser)
raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}")
time.sleep(0.5)
evidence_shot(browser, "contract-goods-picker")
except Exception as exc:
browser.switch_to_default_content()
_scroll_to_goods_table(browser)
evidence_shot(browser, "contract-goods-pick-failed", failed=True)
raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc
browser.switch_to_default_content()
_click_dialog_ok(browser)
# 在商品弹窗搜索框填关键字并点搜索,等到出现含关键字的行。
def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None:
browser.fill_first(GOODS_PICKER["search_input"], keyword)
browser.execute_script(
"""
const el = document.querySelector('input.search-box-text, input[placeholder*="关键字"]');
if (!el) return false;
el.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keyup', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true}));
if (el.form) { el.form.dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); }
return true;
"""
)
try:
browser.click_native(GOODS_PICKER["search_button"])
except Exception:
try:
browser.click_first(GOODS_PICKER["search_button"])
except Exception:
pass
deadline = time.time() + 6
while time.time() < deadline:
if _dialog_row_has_keyword(browser, keyword):
return
time.sleep(0.4)
# 商品弹窗表格里是否已有含该关键字且带勾选框的行。
def _dialog_row_has_keyword(browser: BrowserAdapter, keyword: str) -> bool:
result = browser.execute_script(
"""
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
if (!keyword) return false;
return Array.from(document.querySelectorAll('tr')).some((row) => {
const text = (row.innerText || '').replace(/\\s+/g, '');
return text.indexOf(keyword) >= 0 && !!row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
});
""",
keyword,
)
return result is True
# 按关键字勾中商品弹窗里对应行。勾到返回 True。
def _select_dialog_row_by_keyword(browser: BrowserAdapter, keyword: str) -> bool:
picked = browser.execute_script(
"""
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
if (!keyword) return {ok: false};
const rows = Array.from(document.querySelectorAll('tr'));
for (const row of rows) {
const text = (row.innerText || '').replace(/\\s+/g, '');
if (text.indexOf(keyword) < 0) continue;
const box = row.querySelector(
'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]'
);
if (box) {
if (!box.checked) { box.click(); }
return {ok: true, text: (row.innerText || '').replace(/\\s+/g, ' ').slice(0, 80)};
}
row.click();
return {ok: true, text: 'row-click'};
}
return {ok: false, rows: rows.length};
""",
keyword,
)
return isinstance(picked, dict) and bool(picked.get("ok"))
# 把「选择商品」滚进视口,方便后续点添加行。
def _scroll_to_goods_table(browser: BrowserAdapter) -> None:
try:
browser.execute_script(
"""
const hit = document.querySelector('div[title="选择商品"]')
|| Array.from(document.querySelectorAll('div, a, span, button'))
.find((el) => (el.textContent || '').trim() === '选择商品');
if (hit) hit.scrollIntoView({block: 'center'});
"""
)
time.sleep(0.3)
except Exception:
pass
# 点商品弹窗的「确定」。原生点不到就走脚本。
def _click_dialog_ok(browser: BrowserAdapter) -> None:
try:
browser.click_native(
[
"//*[contains(@class,'lui_dialog')]//*[normalize-space()='确定']",
".lui_dialog_main div.lui_widget_btn_txt",
"text=确定",
]
)
return
except Exception:
pass
result = browser.execute_script(
"""
const root = document.querySelector('.lui_dialog_main') || document;
const btns = Array.from(root.querySelectorAll('div, a, span, button')).filter((el) => {
const text = (el.textContent || '').trim();
return text === '确定' && el.children.length === 0;
});
if (!btns.length) return {ok: false};
btns[btns.length - 1].click();
return {ok: true};
"""
)
if isinstance(result, dict) and result.get("ok"):
return
browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"])
# 把脚本返回的行号收成 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
# 从匹配行号里取第一个不在 skip 里的。没有则返回 -1。
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
# 在明细表里按 MDM/操作码找尚未占用的行。找不到返回 -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_mdmCode'];
const values = fields.map((suffix) => {
const el = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]'
);
return el ? String(el.value || '').trim() : '';
}).filter(Boolean);
if (!values.length) continue;
for (const keyword of keys) {
if (values.some((v) => v === keyword)) {
return i;
}
}
}
return -1;
""",
line.mdm_code or line.goods_code,
line.goods_name,
",".join(str(int(i)) for i in skip),
)
try:
return int(row)
except (TypeError, ValueError):
return -1
# 把前 10 行明细的 MDM/数量/单价收成短文本,失败时写进报错。
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 "")
# 等到明细表出现该货的新行。超时返回 -1。
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)
# 返回明细表里已填 MDM 或操作码的行号。
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)
# 点「添加行」或调 DocList_AddRow,给同品种再占一行。
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
# 把源行的操作码、名称、MDM、批准文号、规格、单位拷到目标行。
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,
)
# 往指定明细行填数量和单价。单价空则写 1.00。
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 "")
if qty:
browser.fill_first([_goods_selector("goods_qty", row)], qty)
price = (line.unit_price or "").strip() or "1.00"
browser.fill_first([_goods_selector("goods_price", row)], price)
# 勾掉身份为空的明细行并点删除行。
def _delete_empty_goods_rows(browser: BrowserAdapter) -> None:
marked = browser.execute_script(
"""
const marked = [];
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)"]');
if (!code && !name) continue;
const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim());
if (!empty) continue;
const row = (code || name).closest('tr');
const box = row ? row.querySelector('input[type="checkbox"]') : null;
if (box && !box.checked) { box.click(); }
marked.push(i);
}
return marked;
"""
)
if marked:
try:
browser.click_first(["text=删除行", "a:contains(删除行)"])
time.sleep(0.5)
except Exception:
pass
# 拼第 row_index 行某个货品字段的 input name 选择器。
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})"]'