606 lines
23 KiB
Python
606 lines
23 KiB
Python
# 合同 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})"]'
|