# -*- coding: utf-8 -*- """demo 子表功能实测 v3(对齐原型 9 子模块 + 工作计划 Tab,客户信息按拍板跳过)。 v1 教训:MANAGE 第一行商机无关联客户,写跟进被必填校验拦下、弹窗未关挡住后续点击。 v2:先遍历定位 A2(关联客户=园科集团(seed快照)),再按 Tab 逐个实测: 跟进真写 / 勘察真写 / 方案卡 seed 断言+新建弹窗+一客户一卡负路径 / 团队 / 附件 / 操作日志。 v3(验收票 03):+ 工作计划 Tab 闭环——seed 三形态断言(逾期/临期/已完成)、 新增、登记完成(finishTime 服务端回填回显)、删除(软删消失)、空内容前端校验负路径。 """ import datetime as dt import io import sys sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') from playwright.sync_api import sync_playwright URL = 'file:///D:/code/crm-backend-matt/.scratch/opportunity-e2e/demo/index.html' BASE = r'D:\code\crm-backend-matt\.scratch\opportunity-e2e' A_UID = '744842565802524672' # 赖永利(A) console_msgs, page_errors, failed = [], [], [] def note(k, v): print('%-30s %s' % (k, v)) with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={'width': 1440, 'height': 960}) page.on('console', lambda m: console_msgs.append((m.type, m.text)) if m.type in ('error', 'warning') else None) page.on('pageerror', lambda e: page_errors.append(str(e))) page.on('requestfailed', lambda r: failed.append((r.url, str(r.failure)))) page.on('response', lambda r: failed.append((r.url, 'HTTP %d' % r.status)) if r.status >= 400 else None) page.goto(URL) page.wait_for_load_state('networkidle') page.click('button:has-text("管理(全量)")') page.wait_for_timeout(1500) # ---- 遍历定位 A2(支持翻页) ---- target = -1 for pg in (1, 2): n = page.locator('#listBody tr.row').count() for i in range(n): page.locator('#listBody tr.row').nth(i).click() page.wait_for_selector('#subTabs', timeout=8000) page.wait_for_timeout(400) page.click('#subTabs button:has-text("关联客户")') page.wait_for_timeout(900) if page.locator('#subBox', has_text='园科集团(seed快照)').count() > 0: target = i note('定位 A2', '第 %d 页第 %d 行(客户=园科集团(seed快照))' % (pg, i + 1)) break page.click('button[data-tab="list"]') page.wait_for_timeout(700) if target >= 0: break try: page.click('.pager button:has-text("下一页")', timeout=3000) page.wait_for_timeout(1200) except Exception: break if target < 0: note('定位 A2', '未找到(所有页都没有 seed 客户),终止') browser.close() sys.exit(1) # ---- 1) 跟进 Tab:真实写一条 ---- page.click('#subTabs button:has-text("跟进")') page.wait_for_timeout(1000) page.click('button:has-text("+ 写跟进")') page.wait_for_selector('#dlgForm textarea[name="followContent"]', timeout=8000) note('跟进弹窗客户下拉', str(page.locator('#dlgForm select[name="customerId"] option').all_text_contents())) page.fill('#dlgForm textarea[name="followContent"]', 'demo-手工验收-测试跟进') page.click('#dlgForm button.ok') try: page.wait_for_selector('#toast .toast-ok', timeout=6000) note('写跟进', '成功(toast-ok)') except Exception: note('写跟进', '未见成功 toast') page.wait_for_timeout(1200) note('跟进回显', '是' if page.locator('#subBox', has_text='demo-手工验收-测试跟进').count() else '否') page.screenshot(path=BASE + r'\demo-sub-3-follow-added.png') # ---- 2) 现场勘察 Tab:seed 断言 + 真实新增 ---- page.click('#subTabs button:has-text("现场勘察")') page.wait_for_timeout(1000) note('seed 勘察记录渲染', '是' if page.locator('#subBox', has_text='机房位置').count() else '否') page.click('button:has-text("+ 新增勘察")') page.wait_for_selector('#dlgForm input[name="engineerUserId"]', timeout=8000) page.fill('#dlgForm input[name="engineerUserId"]', A_UID) page.fill('#dlgForm input[name="surveySeq"]', 'survey_seq_02') page.fill('#dlgForm input[name="applyNo"]', 'KC-DEMO-001') page.fill('#dlgForm textarea[name="surveyDesc"]', 'demo-手工验收-勘察测试') page.click('#dlgForm button.ok') try: page.wait_for_selector('text=勘察已记录', timeout=6000) note('新增勘察', '成功(toast)') except Exception: note('新增勘察', '未见成功 toast') page.wait_for_timeout(1200) note('勘察回显', '是' if page.locator('#subBox', has_text='demo-手工验收-勘察测试').count() else '否') page.screenshot(path=BASE + r'\demo-sub-4-survey.png') # ---- 3) 方案卡 Tab:seed 卡断言 + 详情弹窗 + 新建弹窗 + 一客户一卡负路径 ---- page.click('#subTabs button:has-text("方案卡")') page.wait_for_timeout(1200) note('seed 卡(已提交/88 万)', '是' if page.locator('#subBox', has_text='已提交').count() else '否') page.click('#subBox button:has-text("详情")') page.wait_for_timeout(1000) note('卡详情弹窗字段值', page.locator('#dlgForm .grid .kv').count()) page.screenshot(path=BASE + r'\demo-sub-5-card-detail.png') page.click('#dlgForm button:has-text("取消")') page.wait_for_timeout(500) page.click('button:has-text("新建方案卡")') page.wait_for_timeout(1800) if page.locator('#dlgForm', has_text='新建方案卡').count(): note('新建卡弹窗·模板显示字段输入框', page.locator('#cardFields input').count()) # 负路径客户取「已被本商机占用的客户」触发 66009;无占用客户则退化用伪 id 触发 66007(同拦截族) taken = page.evaluate("""async () => { const r = await fetch(BASE + '/api/opportunity/scheme-card/list?oppId=' + S.oppId, {headers: {Authorization: 'Bearer ' + S.token}}); const b = await r.json(); return (b.data || []).map(c => String(c.customerId)); }""") neg_id = taken[0] if taken else '999999999999999999' neg_code = '66009 一客户一卡' if taken else '66007 客户不属本商机' page.fill('#dlgForm input[name="customerId"]', neg_id) page.click('#dlgForm button.ok') # 保存草稿 → 预期被后端拦截 page.wait_for_timeout(1500) still = page.evaluate("() => document.getElementById('dlg').open") err_txt = '' try: err_txt = page.locator('#toast .toast-err').last.inner_text(timeout=1500) except Exception: pass note('卡保存负路径(%s,negId=%s)' % (neg_code, neg_id), '弹窗仍开=被拦(toast: %s)' % err_txt if still else '意外保存成功(异常,需查)') page.click('#dlgForm button:has-text("取消")') else: note('新建卡弹窗', '未出现(无可选模板版本)') page.screenshot(path=BASE + r'\demo-sub-5-card.png') # ---- 4) 团队成员 Tab(写接口缺失 D-04/05/06,验证只读渲染) ---- page.click('#subTabs button:has-text("团队成员")') page.wait_for_timeout(1200) note('团队成员 .kv 行数', page.locator('#subBox .kv').count()) page.screenshot(path=BASE + r'\demo-sub-6-team.png') # ---- 5) 附件 Tab(上传需 MinIO,demo 只读渲染) ---- page.click('#subTabs button:has-text("附件")') page.wait_for_timeout(1200) note('附件 .kv 行数', page.locator('#subBox .kv').count()) page.screenshot(path=BASE + r'\demo-sub-7-attachment.png') # ---- 6) 工作计划 Tab(票 03:seed 三形态 + add/登记完成/delete 闭环 + 负路径) ---- page.click('#subTabs button:has-text("工作计划")') page.wait_for_timeout(1200) note('wp seed 逾期形态', '是' if page.locator('#subBox .badge.b4', has_text='逾期').count() else '否') note('wp seed 临期形态', '是' if page.locator('#subBox .badge.b3', has_text='临期').count() else '否') note('wp seed 已完成形态', '是' if page.locator('#subBox .badge.b2', has_text='已完成').count() else '否') page.screenshot(path=BASE + r'\demo-sub-8-workplan-seed.png') wp_content = 'demo-验收-wp-闭环测试' page.click('button:has-text("+ 新增工作计划")') page.wait_for_selector('#dlgForm textarea[name="planContent"]', timeout=8000) # 负路径:空内容直接保存 → 前端预校验 errbox page.click('#dlgForm button.ok') page.wait_for_timeout(600) note('wp 空内容负路径', '是' if page.locator('#dlgForm .errbox').count() else '否') # 正路径:填内容 + deadline 未来 7 天(进行中形态) page.fill('#dlgForm textarea[name="planContent"]', wp_content) dl = dt.datetime.now() + dt.timedelta(days=7) page.fill('#dlgForm input[name="deadline"]', dl.strftime('%Y-%m-%dT%H:%M:%S')) page.click('#dlgForm button.ok') try: page.wait_for_selector('#toast .toast-ok', timeout=6000) note('wp 新增', '成功(toast-ok)') except Exception: note('wp 新增', '未见成功 toast') page.wait_for_timeout(1500) row = page.locator('#subBox div[style*="border-bottom"]', has_text=wp_content) note('wp 新增回显(进行中形态)', '是' if row.count() and row.locator('.badge.b1', has_text='进行中').count() else '否') page.screenshot(path=BASE + r'\demo-sub-9-workplan-added.png') # 登记完成 → 服务端回填 finishTime 回显 row.locator('button:has-text("登记完成")').click() try: page.wait_for_selector('#toast .toast-ok', timeout=6000) note('wp 登记完成', '成功(toast-ok)') except Exception: note('wp 登记完成', '未见成功 toast') page.wait_for_timeout(1500) row2 = page.locator('#subBox div[style*="border-bottom"]', has_text=wp_content) note('wp 完成回显(已完成+完成时间)', '是' if row2.count() and row2.locator('.badge.b2', has_text='已完成').count() and row2.locator('text=完成于').count() else '否') page.screenshot(path=BASE + r'\demo-sub-10-workplan-finished.png') # 删除(confirm 对话框自动接受)→ 软删消失 page.once('dialog', lambda d: d.accept()) row2.locator('button:has-text("删除")').click() try: page.wait_for_selector('#toast .toast-ok', timeout=6000) note('wp 删除', '成功(toast-ok)') except Exception: note('wp 删除', '未见成功 toast') page.wait_for_timeout(1500) note('wp 删除后不可见', '是' if page.locator('#subBox', has_text=wp_content).count() == 0 else '否') page.screenshot(path=BASE + r'\demo-sub-11-workplan-deleted.png') # ---- 7) 新建商机(票 03:六必填省市联动 + 成功反馈直达详情) ---- page.click('button[data-tab="list"]') page.wait_for_timeout(700) page.click('header button.primary') # + 新建商机 page.wait_for_selector('#dlgForm input[name="opportunityName"]', timeout=8000) # 负路径:只填名称直接创建 → 前端六必填逐字段 errbox page.fill('#dlgForm input[name="opportunityName"]', 'demo-验收-新建闭环') page.click('#dlgForm button.ok') page.wait_for_timeout(600) eb = page.locator('#crtErr .errbox') note('新建负路径(六必填逐字段提示)', eb.inner_text()[:80].replace('\n', ' ') if eb.count() else '否') page.screenshot(path=BASE + r'\demo-sub-12-create-invalid.png') # 补齐六必填(来源/省/市/属地/招标形式)→ 成功直达详情 page.select_option('#dlgForm select[name="oppSource"]', value='opp_source_02') page.select_option('#dlgForm select[name="provinceCode"]', index=1) page.wait_for_timeout(1200) # 等市级联动 page.select_option('#dlgForm select[name="cityCode"]', index=1) lt_opts = page.locator('#dlgForm select[name="localityType"] option') bf_opts = page.locator('#dlgForm select[name="bidForm"] option') if lt_opts.count() > 1: page.select_option('#dlgForm select[name="localityType"]', index=1) if bf_opts.count() > 1: page.select_option('#dlgForm select[name="bidForm"]', index=1) page.click('#dlgForm button.ok') try: page.wait_for_selector('#toast .toast-ok', timeout=8000) note('新建成功 toast(含 id)', page.locator('#toast .toast-ok').last.inner_text()[:70]) except Exception: note('新建成功 toast', '未见') page.wait_for_timeout(2500) note('新建后直达详情', '是' if page.locator('#detailBox h2', has_text='demo-验收-新建闭环').count() else '否') page.screenshot(path=BASE + r'\demo-sub-13-create-ok-detail.png') # ---- 8) 操作日志 Tab ---- page.click('#subTabs button:has-text("操作日志")') page.wait_for_timeout(1500) note('操作日志条数', page.locator('#subBox div[style*="border-bottom"]').count()) page.screenshot(path=BASE + r'\demo-sub-8-oplog.png') browser.close() print() print('== 健康检查 ==') print('console error/warning:', len(console_msgs)) for t, m in console_msgs: print(' [%s] %s' % (t, m)) print('page errors:', len(page_errors)) for e in page_errors: print(' ', e) print('失败请求(>=400/网络):', len(failed)) for u, w in failed: print(' ', u, w)