新增订单收票付款交货跟进并完善使用升级手册

增加部分付款和部分交货状态、工作台筛选、权限校验、跟进记录及 v4 数据迁移;补充 Windows EXE 构建与验收脚本。附 16 页 PDF 使用升级手册及企业微信详细配置说明、生成脚本和测试截图。验证:76 项回归测试、EXE 浏览器验收与 PDF 逐页校验通过。
This commit is contained in:
reddaniel110
2026-09-16 13:40:43 +08:00
parent bf952196fa
commit 768b4705dc
28 changed files with 3783 additions and 43 deletions
+108 -22
View File
@@ -1,4 +1,6 @@
"""Headless Edge checks against a temporary local server and temporary data only."""
import argparse
import hashlib
import io
import json
import os
@@ -15,8 +17,6 @@ RUN = Path(tempfile.mkdtemp(prefix='ui-check-', dir=OUT))
os.environ['PO_DATA_DIR'] = str(RUN)
sys.path.insert(0, str(ROOT / 'po_app'))
from app import app, Session
from waitress.server import create_server
from playwright.sync_api import sync_playwright
import pymupdf
import openpyxl
@@ -26,14 +26,24 @@ INITIAL = 'UI-test-initial-2026'
PERSONAL = 'UI-test-personal-2026'
def run():
server = create_server(app, host='127.0.0.1', port=0)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
base = 'http://127.0.0.1:%s' % server.effective_port
def run(executable=None):
if executable:
from packaged_server import PackagedServer
server = PackagedServer(executable, RUN)
base = server.base
else:
from app import app, Session
from waitress.server import create_server
server = create_server(app, host='127.0.0.1', port=0)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
base = 'http://127.0.0.1:%s' % server.effective_port
errors = []
summary = []
archives = []
try:
if executable:
server.start()
with sync_playwright() as p:
browser = p.chromium.launch(channel='msedge', headless=True)
def page(mobile=False):
@@ -76,19 +86,30 @@ def run():
tab.wait_for_url(destination)
return tab
accounts = {'maker': sign_in('maker', base + '/')}
# A fake bot URL only: importing the app does not start the sender.
# The EXE starts a real sender. Keep notifications disabled there.
admin.goto(base + '/settings/notifications')
admin.locator('[name=base_url]').fill('http://192.168.1.10:8080')
for channel in ('approval','result'):
admin.locator('[name='+channel+'_webhook]').fill(
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=TEST-ONLY-NOT-A-REAL-KEY-'+channel)
admin.locator('[name=enabled]').check()
if not executable:
admin.locator('[name=enabled]').check()
admin.get_by_role('button',name='保存通知配置').click()
admin.wait_for_load_state('networkidle')
assert admin.locator('[name=approval_webhook]').input_value() == ''
admin.get_by_role('button',name='测试审批通知群',exact=True).click()
admin.wait_for_load_state('networkidle')
if not executable:
admin.get_by_role('button',name='测试审批通知群',exact=True).click()
admin.wait_for_load_state('networkidle')
else:
assert not admin.locator('[name=enabled]').is_checked()
admin.screenshot(path=str(RUN/'notifications.png'), full_page=True)
for path, marker in [('/static/money.js', b'lineCents'),
('/static/style.css', b'seal-company'),
('/materials/import_template.xlsx', b'PK'),
('/suppliers/import_template.xlsx', b'PK'),
('/import_template.xlsx', b'PK')]:
response = admin.request.get(base + path)
assert response.status == 200 and marker in response.body(), path
maker=accounts['maker']
for kind,count in [('purchase',2),('outsourcing',30)]:
maker.goto(base + '/orders/new')
@@ -128,6 +149,7 @@ def run():
response=approver.request.get(view_url.replace('/view','/pdf'))
assert response.status==200
data=response.body();(RUN/(kind+'.pdf')).write_bytes(data)
archives.append((view_url.replace('/view','/pdf'), hashlib.sha256(data).hexdigest()))
document=pymupdf.open(stream=data,filetype='pdf')
text='\n'.join(p.get_text() for p in document)
assert '0.334' in text
@@ -146,6 +168,42 @@ def run():
approver.set_viewport_size({'width':390,'height':844})
summary.append({'kind':kind,'rows':count,'pdf_pages':len(document),'mobile_approval':True})
document.close()
# Follow-up is independent of approval and the frozen PDF.
maker.goto(view_url)
assert maker.locator('#payment-form').count() == 0
maker.locator('[name=needs_prepayment]').select_option('1' if kind == 'purchase' else '0')
maker.locator('[name=invoice_status]').select_option('received')
maker.locator('[name=delivery_status]').select_option('partial' if kind == 'purchase' else 'pending')
maker.locator('[name=fulfillment_note]').fill('已收票;首批到货,跟进剩余交期' if kind == 'purchase' else '供应商备货中')
maker.get_by_role('button', name='保存采购跟进', exact=True).click()
maker.wait_for_load_state('networkidle')
approver.goto(view_url)
assert approver.locator('#follow-up-form').count() == 0
approver.locator('[name=payment_status]').select_option('partial' if kind == 'purchase' else 'paid')
approver.locator('[name=payment_date]').fill(datetime.now().strftime('%Y-%m-%d'))
approver.locator('[name=payment_note]').fill('已付预付款' if kind == 'purchase' else '款项全部付清')
approver.get_by_role('button', name='保存付款状态', exact=True).click()
approver.wait_for_load_state('networkidle')
approver.locator('#order-tracking').scroll_into_view_if_needed()
approver.screenshot(path=str(RUN / (kind+'-mobile-tracking.png')))
assert hashlib.sha256(approver.request.get(view_url.replace('/view','/pdf')).body()).hexdigest() == archives[-1][1]
admin.goto(base + '/')
for key in ('prepayment', 'invoice_due', 'partial_delivery', 'paid_undelivered'):
assert admin.locator('[data-tracking="'+key+'"] .v').inner_text() == '1', key
admin.screenshot(path=str(RUN / 'tracking-dashboard.png'), full_page=True)
admin.locator('[data-tracking="paid_undelivered"]').click()
admin.wait_for_load_state('networkidle')
assert admin.locator('table.grid tr').count() == 2
assert 'WO' in admin.locator('table.grid tr').nth(1).inner_text()
admin.screenshot(path=str(RUN / 'tracking-filter.png'), full_page=True)
for width in (320, 390, 768):
approver.set_viewport_size({'width':width, 'height':844})
for path in (base + '/', view_url + '#order-tracking'):
approver.goto(path)
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, path)
approver.set_viewport_size({'width':390,'height':844})
approver.goto(base + '/')
approver.screenshot(path=str(RUN / 'tracking-dashboard-mobile.png'), full_page=True)
# Native Excel date is visible in the browser date input after upload.
maker.goto(base + '/orders/new');maker.get_by_role('button',name='创建并填写明细').click()
maker.wait_for_url('**/edit')
@@ -156,22 +214,50 @@ def run():
maker.get_by_role('button',name='导入',exact=True).click()
maker.wait_for_load_state('networkidle')
assert maker.locator('[name=item_date]').input_value()=='2026-10-01'
from notifications import dispatch_once
from models import Notification
delivered=[]
for i in range(15):
dispatch_once(Session, lambda *args: delivered.append(args), datetime.utcnow()+timedelta(seconds=i*5))
with Session() as db:
assert db.query(Notification).filter_by(status='sent').count()==3 # Test + two results.
assert db.query(Notification).filter_by(status='cancelled').count()==2 # Superseded tasks.
assert len(delivered)==3
response = admin.request.get(base + '/export/ledger')
assert response.status == 200
ledger = openpyxl.load_workbook(io.BytesIO(response.body()))
assert ledger.active.max_row == 4
ledger.close()
if executable:
import sqlite3
server.restart()
for url, expected in archives:
response = maker.request.get(url)
assert response.status == 200
assert hashlib.sha256(response.body()).hexdigest() == expected
with sqlite3.connect(RUN / 'instance' / 'po.db') as db:
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('PRAGMA user_version').fetchone()[0] == 4
assert db.execute('SELECT count(*) FROM orders WHERE status = ?', ('approved',)).fetchone()[0] == 2
assert db.execute('SELECT count(*) FROM notifications').fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM orders WHERE payment_status='paid' AND delivery_status='pending'").fetchone()[0] == 1
assert not (server.cwd / 'instance').exists()
else:
from notifications import dispatch_once
from models import Notification
delivered=[]
for i in range(15):
dispatch_once(Session, lambda *args: delivered.append(args), datetime.utcnow()+timedelta(seconds=i*5))
with Session() as db:
assert db.query(Notification).filter_by(status='sent').count()==3 # Test + two results.
assert db.query(Notification).filter_by(status='cancelled').count()==2 # Superseded tasks.
assert len(delivered)==3
assert not errors, errors
browser.close()
(RUN/'summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({'result':'passed','scenarios':summary,'output':str(RUN)},ensure_ascii=False))
result = {'result':'passed','scenarios':summary,'output':str(RUN),
'runtime': 'exe' if executable else 'source', 'tracking_dashboard': True}
if executable:
result.update(exe_sha256=hashlib.sha256(Path(executable).read_bytes()).hexdigest(),
restart_preserves_pdf=True, python_on_path=False, notifications_sent=0)
(RUN/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(result,ensure_ascii=False))
finally:
server.close()
if __name__ == '__main__':
run()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--exe', type=Path, help='Test an isolated copy of this single-file EXE')
run(parser.parse_args().exe)