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/` |
|
| 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 菜单 |
|
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
|
||||||
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
|
||||||
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
| 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 |
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
## 测试账号
|
## 测试账号
|
||||||
|
|
||||||
地址:`http://contract-test.gzmpc.com:8080/`
|
地址:`http://contract-test.gzmpc.com:8080/`
|
||||||
账号:`test002 / 1`
|
账号:`10007228 / 1`
|
||||||
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
|
||||||
|
|
||||||
## 页面步骤
|
## 页面步骤
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ class ApaBrowserAdapter:
|
||||||
profile_base = resolve_chrome_profile_base()
|
profile_base = resolve_chrome_profile_base()
|
||||||
if _is_inside_repo(profile_base, REPO_ROOT):
|
if _is_inside_repo(profile_base, REPO_ROOT):
|
||||||
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}")
|
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}")
|
||||||
# 每次进程单独目录:上次 detach 留下的 Chrome 会锁住同一 profile
|
# 每次适配器单独目录:同进程第二组、上次 detach 留下的 Chrome 都会锁住同一 profile
|
||||||
profile_dir = profile_base / str(os.getpid())
|
profile_dir = profile_base / f"{os.getpid()}-{id(self)}"
|
||||||
os.makedirs(profile_dir, exist_ok=True)
|
os.makedirs(profile_dir, exist_ok=True)
|
||||||
profile_dir = str(profile_dir)
|
profile_dir = str(profile_dir)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class ContractCreateInput:
|
||||||
contract_system_no: str | None = None
|
contract_system_no: str | None = None
|
||||||
attempt_no: int = 1
|
attempt_no: int = 1
|
||||||
contract_url: str = CONTRACT_URL
|
contract_url: str = CONTRACT_URL
|
||||||
username: str = "test002"
|
username: str = "10007228"
|
||||||
password: str = "1"
|
password: str = "1"
|
||||||
submit_mode: str = "submit"
|
submit_mode: str = "submit"
|
||||||
order_no_list: tuple[str, ...] = ()
|
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")),
|
contract_system_no=_opt(data.get("contractSystemNo")),
|
||||||
attempt_no=int(data.get("attemptNo", 1) or 1),
|
attempt_no=int(data.get("attemptNo", 1) or 1),
|
||||||
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
|
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")),
|
password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")),
|
||||||
submit_mode=draft.submit_mode,
|
submit_mode=draft.submit_mode,
|
||||||
order_no_list=tuple(draft.order_no_list),
|
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("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO"))
|
||||||
parser.add_argument("--attempt-no", type=int, default=1)
|
parser.add_argument("--attempt-no", type=int, default=1)
|
||||||
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL))
|
||||||
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002"))
|
parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "10007228"))
|
||||||
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1"))
|
||||||
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH"))
|
||||||
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT"))
|
||||||
|
|
|
||||||
|
|
@ -113,17 +113,23 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext)
|
||||||
fill_contract_goods_lines(browser, ctx)
|
fill_contract_goods_lines(browser, ctx)
|
||||||
fill_contract_payment_info(browser, ctx)
|
fill_contract_payment_info(browser, ctx)
|
||||||
upload_contract_document_if_needed(browser, ctx)
|
upload_contract_document_if_needed(browser, ctx)
|
||||||
|
fd_id = _read_document_id(browser)
|
||||||
if not submit_contract_for_approval(browser, ctx):
|
if not submit_contract_for_approval(browser, ctx):
|
||||||
if ctx.submit_mode == "dry_run":
|
if ctx.submit_mode == "dry_run":
|
||||||
_ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True)
|
_ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True)
|
||||||
return ""
|
return ""
|
||||||
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
|
_ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False)
|
||||||
return ""
|
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(
|
_ACCEPT.note(
|
||||||
"S13",
|
"S13",
|
||||||
"读合同号",
|
"读合同号",
|
||||||
{"contractNo": contract_no or "(none)", "via": "我的合同/申请时间/第一行"},
|
{
|
||||||
|
"contractNo": contract_no or "(none)",
|
||||||
|
"via": "我的合同查询",
|
||||||
|
"fdId": fd_id or "(none)",
|
||||||
|
},
|
||||||
ok=_usable_contract_no(contract_no),
|
ok=_usable_contract_no(contract_no),
|
||||||
)
|
)
|
||||||
return contract_no
|
return contract_no
|
||||||
|
|
@ -242,7 +248,6 @@ ADD_ROW_SELECTORS = [
|
||||||
"text=添加行",
|
"text=添加行",
|
||||||
"text=新增明细",
|
"text=新增明细",
|
||||||
"text=新增行",
|
"text=新增行",
|
||||||
"text=新增",
|
|
||||||
"button:contains(添加行)",
|
"button:contains(添加行)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -309,6 +314,10 @@ def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) ->
|
||||||
evidence_shot(browser, "contract-already-logged-in")
|
evidence_shot(browser, "contract-already-logged-in")
|
||||||
_ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True)
|
_ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True)
|
||||||
return
|
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_username"]', 'input[placeholder="用户名"]'], ctx.username)
|
||||||
browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password)
|
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.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")
|
evidence_shot(browser, "contract-goods-lines-filled")
|
||||||
_ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True)
|
_ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True)
|
||||||
return
|
return
|
||||||
|
used: list[int] = []
|
||||||
|
assigned: list[int] = []
|
||||||
for line in ctx.goods_lines:
|
for line in ctx.goods_lines:
|
||||||
_pick_goods_from_dialog(browser, line)
|
row = _find_goods_row(browser, line, skip=used)
|
||||||
_fill_goods_required_qty_price(browser, line)
|
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)
|
_delete_empty_goods_rows(browser)
|
||||||
evidence_shot(browser, "contract-goods-lines-filled")
|
evidence_shot(browser, "contract-goods-lines-filled")
|
||||||
for index, line in enumerate(ctx.goods_lines):
|
for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)):
|
||||||
row = _find_goods_row(browser, line)
|
opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else ""
|
||||||
code = _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 ""
|
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 ""
|
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(
|
_ACCEPT.check(
|
||||||
"S09",
|
"S09",
|
||||||
f"货品明细#{index + 1}",
|
f"货品明细#{index + 1}",
|
||||||
[
|
[
|
||||||
("goodsCode", code, line.goods_code),
|
code_pair,
|
||||||
("qty", qty, line.quantity),
|
("qty", qty, line.quantity),
|
||||||
("price", price, line.unit_price or "1.00"),
|
("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:
|
def _goods_search_keyword(line: ContractGoodsLine) -> str:
|
||||||
keyword = (line.goods_code or line.goods_name or "").strip()
|
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
|
||||||
if not keyword:
|
|
||||||
raise NodeError("货品缺少 goodsCode/goodsName,无法在「选择商品」中搜索")
|
|
||||||
|
|
||||||
|
|
||||||
|
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.click_first(GOODS_PICKER["trigger"])
|
||||||
browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15)
|
browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15)
|
||||||
browser.switch_to_frame(GOODS_PICKER["frame"])
|
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
|
raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc
|
||||||
browser.switch_to_default_content()
|
browser.switch_to_default_content()
|
||||||
_click_dialog_ok(browser)
|
_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:
|
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=确定"])
|
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(
|
row = browser.execute_script(
|
||||||
"""
|
"""
|
||||||
const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean);
|
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++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (skip[i]) continue;
|
||||||
const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno'];
|
const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno'];
|
||||||
const values = fields.map((suffix) => {
|
const values = fields.map((suffix) => {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
|
|
@ -777,8 +859,9 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int:
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
""",
|
""",
|
||||||
line.goods_code,
|
line.mdm_code or line.goods_code,
|
||||||
line.goods_name,
|
line.goods_name,
|
||||||
|
",".join(str(int(i)) for i in skip),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return int(row)
|
return int(row)
|
||||||
|
|
@ -786,8 +869,211 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine) -> None:
|
def _goods_table_dump(browser: BrowserAdapter) -> str:
|
||||||
row = _find_goods_row(browser, line)
|
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:
|
if row < 0:
|
||||||
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
|
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
|
||||||
qty = str(line.quantity if line.quantity else "")
|
qty = str(line.quantity if line.quantity else "")
|
||||||
|
|
@ -882,18 +1168,24 @@ 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()
|
path = (ctx.contract_document_path or "").strip()
|
||||||
if not path and ctx.source_type == "YYC":
|
if not path and ctx.source_type == "YYC":
|
||||||
path = (ctx.merged_pdf_path or "").strip()
|
path = (ctx.merged_pdf_path or "").strip()
|
||||||
|
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:
|
if not path:
|
||||||
if ctx.submit_mode == "dry_run":
|
evidence_shot(browser, "contract-pdf-upload-skipped")
|
||||||
evidence_shot(browser, "contract-pdf-upload-skipped")
|
_ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True)
|
||||||
_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
|
return
|
||||||
try:
|
try:
|
||||||
_upload_contract_body(browser, path)
|
_upload_contract_body(browser, path)
|
||||||
|
|
@ -1070,36 +1362,62 @@ def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowConte
|
||||||
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
|
_ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True)
|
||||||
return False
|
return False
|
||||||
_close_blocking_dialogs(browser)
|
_close_blocking_dialogs(browser)
|
||||||
fd_id = _read_document_id(browser)
|
# 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号仍从我的合同查。
|
||||||
try:
|
try:
|
||||||
browser.click_native(
|
_click_toolbar_button(browser, "暂存")
|
||||||
[
|
except Exception as exc:
|
||||||
"//*[normalize-space()='暂存']/following::*[normalize-space()='提交'][1]",
|
print(f"暂存未点到:{exc}", flush=True)
|
||||||
"text=提交",
|
saved = _wait_after_save_or_submit(browser, timeout_seconds=15)
|
||||||
]
|
blocked = _submit_validation_error(browser)
|
||||||
)
|
if blocked:
|
||||||
except Exception:
|
evidence_shot(browser, "contract-submitted", failed=True)
|
||||||
_click_top_submit(browser)
|
_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")
|
evidence_shot(browser, "contract-after-submit-click")
|
||||||
_confirm_submit_dialogs(browser)
|
submitted = _wait_after_save_or_submit(browser, timeout_seconds=20)
|
||||||
deadline = time.time() + 5
|
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:
|
while time.time() < deadline:
|
||||||
if _submit_succeeded(browser):
|
if _submit_validation_error(browser):
|
||||||
evidence_shot(browser, "contract-submitted")
|
return False
|
||||||
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)
|
_confirm_submit_dialogs(browser)
|
||||||
|
if _submit_succeeded(browser):
|
||||||
|
return True
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
evidence_shot(browser, "contract-submitted", failed=True)
|
return not bool(_submit_validation_error(browser)) and _submit_succeeded(browser)
|
||||||
print("提交后仍在起草页,浏览器保持打开,请手工检查。", flush=True)
|
|
||||||
_ACCEPT.note("S12", "提交", {"submitted": "no", "page": "draft"}, ok=False, raise_error=False)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
||||||
body = browser.read_body_text() or ""
|
body = browser.read_body_text() or ""
|
||||||
|
if _submit_error_from_body(body):
|
||||||
|
return False
|
||||||
if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")):
|
if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")):
|
||||||
return True
|
return True
|
||||||
url = ""
|
url = ""
|
||||||
|
|
@ -1110,6 +1428,28 @@ def _submit_succeeded(browser: BrowserAdapter) -> bool:
|
||||||
return "method=add" not in url and "kmAgreementApply" in url
|
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:
|
def _read_document_id(browser: BrowserAdapter) -> str:
|
||||||
value = browser.execute_script(
|
value = browser.execute_script(
|
||||||
"""
|
"""
|
||||||
|
|
@ -1138,36 +1478,52 @@ def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None:
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
|
||||||
def _click_top_submit(browser: BrowserAdapter) -> None:
|
def _click_toolbar_button(browser: BrowserAdapter, label: str) -> None:
|
||||||
clicked = browser.execute_script(
|
clicked = browser.execute_script(
|
||||||
"""
|
"""
|
||||||
const toolbar = document.querySelector('.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]');
|
const label = String(arguments[0] || '');
|
||||||
const root = toolbar || document;
|
function visible(el) {
|
||||||
const hits = Array.from(root.querySelectorAll('div, a, button, span')).filter((el) => {
|
const box = el.getBoundingClientRect();
|
||||||
return (el.textContent || '').trim() === '提交' && el.children.length === 0;
|
return box.width > 2 && box.height > 2;
|
||||||
});
|
}
|
||||||
if (!hits.length) return {ok: false};
|
function clickIn(win) {
|
||||||
const save = Array.from(document.querySelectorAll('div, a, span')).find((el) => {
|
const toolbar = win.document.querySelector(
|
||||||
return (el.textContent || '').trim() === '暂存' && el.children.length === 0;
|
'.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]'
|
||||||
});
|
);
|
||||||
const target = (save && hits.find((el) => {
|
const roots = [toolbar, win.document].filter(Boolean);
|
||||||
const a = save.getBoundingClientRect();
|
for (const root of roots) {
|
||||||
const b = el.getBoundingClientRect();
|
const hits = Array.from(root.querySelectorAll('div, a, button, span, li')).filter((el) => {
|
||||||
return Math.abs(a.top - b.top) < 40;
|
return (el.textContent || '').replace(/\\s+/g, '').trim() === label && visible(el);
|
||||||
})) || hits[0];
|
});
|
||||||
target.click();
|
hits.sort((a, b) => a.children.length - b.children.length);
|
||||||
return {ok: true};
|
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"):
|
if isinstance(clicked, dict) and clicked.get("ok"):
|
||||||
return
|
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:
|
def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
|
||||||
browser.execute_script(
|
browser.execute_script(
|
||||||
"""
|
"""
|
||||||
const labels = ['确定', '确认', '是', '提交', 'OK'];
|
const labels = ['确定', '确认', '是', 'OK'];
|
||||||
const nodes = Array.from(document.querySelectorAll('div, a, button, span, input'));
|
const nodes = Array.from(document.querySelectorAll('div, a, button, span, input'));
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const hit = nodes.find((el) => {
|
const hit = nodes.find((el) => {
|
||||||
|
|
@ -1182,7 +1538,9 @@ def _confirm_submit_dialogs(browser: BrowserAdapter) -> None:
|
||||||
time.sleep(0.8)
|
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:
|
if ctx is None:
|
||||||
ctx = ContractFlowContext(
|
ctx = ContractFlowContext(
|
||||||
contract_url="http://contract-test.gzmpc.com:8080/",
|
contract_url="http://contract-test.gzmpc.com:8080/",
|
||||||
|
|
@ -1192,7 +1550,7 @@ def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | Non
|
||||||
order_date="",
|
order_date="",
|
||||||
source_type="",
|
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:
|
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()
|
browser.switch_to_default_content()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
scanned = browser.execute_script(
|
||||||
"""
|
"""
|
||||||
const skip = ['提交时自动生成', '自动生成', '==请选择=='];
|
const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题'];
|
||||||
const inputs = document.querySelectorAll(
|
function fromDoc(doc) {
|
||||||
'input[name="fdNumber"], input[name="contractNo"], input[name*="fdNumber"]'
|
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();
|
for (const el of inputs) {
|
||||||
if (v && skip.indexOf(v) < 0) return v;
|
const v = (el.value || '').trim();
|
||||||
|
if (v && skip.indexOf(v) < 0) return v;
|
||||||
|
}
|
||||||
|
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 !== '合同编号:' && 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 '';
|
||||||
}
|
}
|
||||||
const nodes = Array.from(document.querySelectorAll('td, th, span, div, label, em, b'));
|
function walk(win) {
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
try {
|
||||||
const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim();
|
const no = fromDoc(win.document);
|
||||||
if (label !== '合同编号' && label !== '合同编号:') continue;
|
if (no) return no;
|
||||||
const next = nodes[i + 1];
|
} catch (e) {}
|
||||||
const n = next ? ((next.innerText || next.value || '').replace(/\\s+/g, ' ').trim()) : '';
|
for (let i = 0; i < win.frames.length; i++) {
|
||||||
if (n && skip.indexOf(n) < 0) return n;
|
try {
|
||||||
|
const no = walk(win.frames[i]);
|
||||||
|
if (no) return no;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
return '';
|
return walk(window);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
|
if isinstance(scanned, str) and _usable_contract_no(scanned.strip()):
|
||||||
return scanned.strip()
|
return scanned.strip()
|
||||||
|
if scanned:
|
||||||
|
print(f"S13 详情页扫到不可用值={scanned!r}", flush=True)
|
||||||
body = browser.read_body_text() or ""
|
body = browser.read_body_text() or ""
|
||||||
for line in body.splitlines():
|
for line in body.splitlines():
|
||||||
text = line.strip()
|
text = line.strip()
|
||||||
|
|
@ -1423,24 +1789,258 @@ def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str:
|
||||||
return ""
|
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(
|
def _read_contract_number_from_my_list(
|
||||||
browser: BrowserAdapter,
|
browser: BrowserAdapter,
|
||||||
ctx: ContractFlowContext,
|
ctx: ContractFlowContext,
|
||||||
timeout_seconds: int = 15,
|
timeout_seconds: int = 25,
|
||||||
|
fd_id: str = "",
|
||||||
) -> 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)
|
_open_my_contracts(browser, ctx)
|
||||||
_sort_my_contracts_by_apply_time(browser)
|
|
||||||
deadline = time.time() + timeout_seconds
|
deadline = time.time() + timeout_seconds
|
||||||
last = ""
|
searched = False
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
last = _scrape_latest_my_contract_number(browser)
|
rows = _scrape_my_contract_rows(browser)
|
||||||
if last:
|
print(f"S13 列表行数={len(rows)}", flush=True)
|
||||||
print("从我的合同读到最新合同编号:", last, flush=True)
|
for row in rows[:6]:
|
||||||
return last
|
print(
|
||||||
time.sleep(0.3)
|
"S13 row"
|
||||||
print("我的合同列表未读到合同编号", flush=True)
|
f" no={row.get('no')!r}"
|
||||||
return last
|
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:
|
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码"
|
return "缺少供应商MDM码"
|
||||||
if not (rows[0].supplier_mdm_code or "").strip():
|
if not (rows[0].supplier_mdm_code or "").strip():
|
||||||
return "缺少供应商MDM码"
|
return "缺少供应商MDM码"
|
||||||
|
for row in rows:
|
||||||
|
if not (row.goods_mdm_code or "").strip():
|
||||||
|
return "缺少货品MDM码"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,14 +61,14 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
|
||||||
)
|
)
|
||||||
goods = [
|
goods = [
|
||||||
ContractGoodsLine(
|
ContractGoodsLine(
|
||||||
operation_code=row.goods_id,
|
operation_code=(row.goods_mdm_code or "").strip(),
|
||||||
goods_code=row.goods_id,
|
goods_code=(row.goods_mdm_code or "").strip(),
|
||||||
goods_name=row.goods_name,
|
goods_name=row.goods_name,
|
||||||
approval_no=row.reg_no,
|
approval_no=row.reg_no,
|
||||||
spec=row.spec,
|
spec=row.spec,
|
||||||
unit=row.unit,
|
unit=row.unit,
|
||||||
quantity=float(row.qty),
|
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),
|
unit_price=money4(row.unit_price),
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
"""合同 PDF 合并。"""
|
"""合同 PDF 合并、提交占位正文。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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:
|
def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str:
|
||||||
"""Return one PDF path, merging multiple source PDFs when needed.
|
"""Return one PDF path, merging multiple source PDFs when needed.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from contract_flow_steps import (
|
from contract_flow_steps import (
|
||||||
ContractFlowContext,
|
ContractFlowContext,
|
||||||
begin_date_for,
|
begin_date_for,
|
||||||
check_customer_diligence,
|
check_customer_diligence,
|
||||||
sign_date_for,
|
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
|
from errors import DiligenceError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -48,3 +55,82 @@ def test_diligence_raises_when_page_says_not_done():
|
||||||
|
|
||||||
def test_diligence_passes_when_text_absent():
|
def test_diligence_passes_when_text_absent():
|
||||||
check_customer_diligence(_FakeBrowser("客户已尽调"), _ctx())
|
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.source_type == ""
|
||||||
assert draft.is_prepayment == "否"
|
assert draft.is_prepayment == "否"
|
||||||
assert len(draft.goods_lines) == 2
|
assert len(draft.goods_lines) == 2
|
||||||
assert draft.goods_lines[0].goods_code == "G0001"
|
assert draft.goods_lines[0].goods_code == "QX0100167583"
|
||||||
assert draft.goods_lines[0].operation_code == "G0001"
|
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.goods_lines[0].unit_price == "100.0000"
|
||||||
assert draft.belong_year == "2026"
|
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():
|
def test_missing_mdm_reason():
|
||||||
rows = [_row(supplier_mdm_code="")]
|
rows = [_row(supplier_mdm_code="")]
|
||||||
assert missing_mdm_reason(rows) == "缺少供应商MDM码"
|
assert missing_mdm_reason(rows) == "缺少供应商MDM码"
|
||||||
|
assert missing_mdm_reason([_row(goods_mdm_code="")]) == "缺少货品MDM码"
|
||||||
assert missing_mdm_reason([_row()]) is None
|
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)
|
run_batch(repo, runner=runner)
|
||||||
assert len(calls) == 1
|
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 calls[0].customer_code == "DW0100046361"
|
||||||
assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502]
|
assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502]
|
||||||
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue