feat: 合同页按 MDM 搜货并按 fdId 读号

合单缺货品 MDM 直接失败;页面按 MDM 搜货,同品各占一行。
提交前读 fdId,回我的合同按文档/标题+客户编码匹配。
无平台正文时挂占位 PDF;Chrome 用户目录按适配器实例拆开。
默认登录账号改为 10007228。
This commit is contained in:
34BFF76918A9449ABAB323149DDCEA73 2026-10-09 11:24:54 +08:00
parent 3ccda7d6c2
commit bf0294a12d
11 changed files with 891 additions and 131 deletions

View File

@ -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` | 正文含「未尽调」则失败,不提交 |

View File

@ -15,7 +15,7 @@
## 测试账号 ## 测试账号
地址:`http://contract-test.gzmpc.com:8080/` 地址:`http://contract-test.gzmpc.com:8080/`
账号:`test002 / 1` 账号:`10007228 / 1`
客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。 客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。
## 页面步骤 ## 页面步骤

View File

@ -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)

View File

@ -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"))

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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) == ""

View File

@ -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

View File

@ -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"}