完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""Create a consistent SQLite backup without copying a live database file."""
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def backup_database(data_dir):
|
||||
instance = Path(data_dir).resolve() / 'instance'
|
||||
source = instance / 'po.db'
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError('未找到数据库:%s' % source)
|
||||
destination_dir = instance / 'backups'
|
||||
destination_dir.mkdir(exist_ok=True)
|
||||
destination = destination_dir / ('po-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.db')
|
||||
with sqlite3.connect(source.as_uri() + '?mode=ro', uri=True) as original:
|
||||
with sqlite3.connect(destination) as copy:
|
||||
original.backup(copy)
|
||||
if copy.execute('PRAGMA integrity_check').fetchone()[0] != 'ok':
|
||||
raise RuntimeError('备份完整性检查未通过:%s' % destination)
|
||||
return destination
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--data-dir', default=os.environ.get('PO_DATA_DIR', str(Path(__file__).resolve().parents[1] / 'po_app')))
|
||||
args = parser.parse_args()
|
||||
print(backup_database(args.data_dir))
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Headless Edge checks against a temporary local server and temporary data only."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / 'tools' / 'out'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
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
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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
|
||||
errors = []
|
||||
summary = []
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel='msedge', headless=True)
|
||||
def page(mobile=False):
|
||||
context = browser.new_context(viewport={'width':390,'height':844} if mobile else {'width':1440,'height':1080},
|
||||
is_mobile=mobile, has_touch=mobile)
|
||||
tab = context.new_page()
|
||||
tab.on('pageerror', lambda e: errors.append(str(e)))
|
||||
tab.on('dialog', lambda d: d.accept())
|
||||
return tab
|
||||
admin = page()
|
||||
admin.goto(base + '/setup')
|
||||
admin.locator('[name=setup_token]').fill((RUN / 'instance' / 'setup.token').read_text().strip())
|
||||
admin.locator('[name=username]').fill('admin')
|
||||
admin.locator('[name=name]').fill('管理员')
|
||||
admin.locator('[name=password]').fill(PERSONAL)
|
||||
admin.get_by_role('button',name='创建管理员').click()
|
||||
admin.wait_for_url(base + '/')
|
||||
for username, role in [('maker','maker'),('approver','approver')]:
|
||||
admin.goto(base + '/users')
|
||||
admin.locator('[name=username]').fill(username)
|
||||
admin.locator('[name=name]').fill({'maker':'采购员','approver':'审批员'}[username])
|
||||
admin.locator('[name=role]').select_option(role)
|
||||
admin.locator('[name=password]').fill(INITIAL)
|
||||
admin.get_by_role('button',name='保存用户').click()
|
||||
admin.wait_for_url(base + '/users')
|
||||
admin.screenshot(path=str(RUN / 'users.png'),full_page=True)
|
||||
def sign_in(username, destination):
|
||||
tab=page(mobile=username != 'maker'); tab.goto(destination)
|
||||
assert '/login' in tab.url
|
||||
if username != 'maker':
|
||||
tab.screenshot(path=str(RUN / (username+'-mobile-login.png')))
|
||||
tab.locator('[name=username]').fill(username)
|
||||
tab.locator('[name=password]').fill(INITIAL)
|
||||
tab.get_by_role('button',name='登 录').click()
|
||||
tab.wait_for_url(re.compile(r'/account/password(?:\?|$)'))
|
||||
tab.locator('[name=old_password]').fill(INITIAL)
|
||||
tab.locator('[name=password]').fill(PERSONAL)
|
||||
tab.locator('[name=confirm_password]').fill(PERSONAL)
|
||||
tab.get_by_role('button',name='更新密码').click()
|
||||
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.
|
||||
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()
|
||||
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')
|
||||
admin.screenshot(path=str(RUN/'notifications.png'), full_page=True)
|
||||
maker=accounts['maker']
|
||||
for kind,count in [('purchase',2),('outsourcing',30)]:
|
||||
maker.goto(base + '/orders/new')
|
||||
maker.locator('[name=doc_type]').select_option(kind)
|
||||
maker.get_by_role('button',name='创建并填写明细').click()
|
||||
maker.wait_for_url('**/edit')
|
||||
edit_url=maker.url
|
||||
maker.locator('[name=supplier_name]').fill('测试供应商')
|
||||
maker.locator('[name=payment_terms]').fill('月结30天')
|
||||
for i in range(count):
|
||||
maker.get_by_role('button',name='+ 添加一行').click()
|
||||
maker.evaluate('''count => {
|
||||
document.querySelectorAll('#itemTable tr').forEach((tr,i) => {
|
||||
if (!i) return;
|
||||
const vals={code:'BROWSER-'+i,spec:'测试物料 A&B <规格>',unit:'件',qty:'1',price:'0.334',date:'2026-10-01',remark:''};
|
||||
for (const [k,v] of Object.entries(vals)) tr.querySelector('[name=item_'+k+']').value=v;
|
||||
});
|
||||
calc();
|
||||
}''',count)
|
||||
assert maker.locator('#totalAmt').inner_text() == ('0.66' if count==2 else '9.90')
|
||||
maker.screenshot(path=str(RUN / (kind+'-edit.png')),full_page=True)
|
||||
maker.get_by_role('button',name='保存并提交审批').click()
|
||||
maker.wait_for_url('**/view')
|
||||
view_url=maker.url
|
||||
assert maker.locator('h1').inner_text().endswith('待审批')
|
||||
if 'approver' not in accounts:
|
||||
accounts['approver'] = sign_in('approver', view_url)
|
||||
approver=accounts['approver'];approver.goto(view_url)
|
||||
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
approver.get_by_role('button',name='加盖采购专用章',exact=True).click()
|
||||
approver.locator('#apprConfirmBtn').scroll_into_view_if_needed()
|
||||
approver.screenshot(path=str(RUN / (kind+'-mobile-approve.png')))
|
||||
approver.get_by_role('button',name='确认审批通过',exact=True).click()
|
||||
approver.wait_for_load_state('networkidle')
|
||||
assert '审批通过' in approver.locator('h1').inner_text()
|
||||
approver.screenshot(path=str(RUN / (kind+'-approved.png')),full_page=True)
|
||||
response=approver.request.get(view_url.replace('/view','/pdf'))
|
||||
assert response.status==200
|
||||
data=response.body();(RUN/(kind+'.pdf')).write_bytes(data)
|
||||
document=pymupdf.open(stream=data,filetype='pdf')
|
||||
text='\n'.join(p.get_text() for p in document)
|
||||
assert '0.334' in text
|
||||
assert '待审批' not in text
|
||||
assert text.count('采购专用章') == 1
|
||||
assert '审核专用章' not in text and '批准专用章' not in text
|
||||
for i,pdf_page in enumerate(document):
|
||||
pdf_page.get_pixmap(dpi=110).save(RUN/('%s-%s.png'%(kind,i+1)))
|
||||
for width in (320,390,768):
|
||||
approver.set_viewport_size({'width':width, 'height':844})
|
||||
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), width
|
||||
for route in ('/orders', '/'):
|
||||
approver.goto(base + route)
|
||||
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, route)
|
||||
approver.goto(view_url)
|
||||
approver.set_viewport_size({'width':390,'height':844})
|
||||
summary.append({'kind':kind,'rows':count,'pdf_pages':len(document),'mobile_approval':True})
|
||||
document.close()
|
||||
# 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')
|
||||
workbook=openpyxl.Workbook();workbook.active.append(['物料代码','物料规格','单位','数量','单价','交期','备注'])
|
||||
workbook.active.append(['DATE','日期测试','件',1,2,datetime(2026,10,1),''])
|
||||
out=io.BytesIO();workbook.save(out)
|
||||
maker.locator('[name=file]').set_input_files({'name':'采购明细.xlsx','mimeType':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','buffer':out.getvalue()})
|
||||
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
|
||||
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))
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
+3
-97
@@ -1,99 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""端到端冒烟:制单 → 提交 → 主管审核 → 经理批准 → 导出 PDF。"""
|
||||
import http.cookiejar
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
OUT = r"D:\2026-09-09-task-18\tools\out"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def new_session():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
|
||||
def post(op, path, data):
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
req = urllib.request.Request(BASE + path, data=body)
|
||||
r = op.open(req, timeout=30)
|
||||
return r.geturl(), r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def get(op, path):
|
||||
r = op.open(BASE + path, timeout=30)
|
||||
return r.geturl(), r.read()
|
||||
|
||||
|
||||
def login(user, pwd):
|
||||
op = new_session()
|
||||
url, html = post(op, "/login", {"username": user, "password": pwd})
|
||||
assert "/login" not in url, "%s 登录失败" % user
|
||||
print(" login %s ok" % user)
|
||||
return op
|
||||
|
||||
|
||||
def make_order(op, doc_type, supplier_id, n_items):
|
||||
url, _ = post(op, "/orders/new", {"doc_type": doc_type, "supplier_id": supplier_id})
|
||||
m = re.search(r"/orders/(\d+)", url)
|
||||
oid = int(m.group(1))
|
||||
form = [
|
||||
("order_date", "2026-09-09"), ("tax_rate", "13%"),
|
||||
("supplier_name", "苏州精锐五金制品有限公司"),
|
||||
("supplier_contact", "周经理"), ("supplier_phone", "13800000001"),
|
||||
("payment_terms", "月结30天"), ("supplier_tax_no", "91320500MA1XXXXXXX"),
|
||||
("supplier_bank_account", "4562 1088 0012 3456"),
|
||||
("supplier_bank_name", "中国银行苏州工业园区支行"),
|
||||
("our_company", "苏州博特维智能科技有限公司"),
|
||||
("our_contact", "胡乃琛"), ("our_phone", "13913172516"),
|
||||
("our_tax_no", "91320594MA1YYYYYYY"),
|
||||
("delivery_place", "苏州市工业园区星汉街5号腾飞新苏工业坊B幢5楼05-06单元 胡乃琛 13913172516"),
|
||||
("note", "本单为系统测试单,含 & <特殊> 字符校验"),
|
||||
]
|
||||
for i in range(n_items):
|
||||
form += [
|
||||
("item_code", "M-10%02d" % (i + 1)),
|
||||
("item_spec", "测试物料 规格 A&B <%d>" % (i + 1)),
|
||||
("item_unit", "件"), ("item_qty", "12.5"), ("item_price", "38.5"),
|
||||
("item_date", "2026-09-20"), ("item_remark", "行%d" % (i + 1)),
|
||||
]
|
||||
post(op, "/orders/%d" % oid, form)
|
||||
return oid
|
||||
|
||||
|
||||
def run(tag, doc_type, n_items):
|
||||
print("[%s] doc=%s items=%d" % (tag, doc_type, n_items))
|
||||
maker = login("maker", "123456")
|
||||
oid = make_order(maker, doc_type, 1, n_items)
|
||||
print(" 制单 ok, id=%d" % oid)
|
||||
post(maker, "/orders/%d/submit" % oid, {"comment": "请审核"})
|
||||
|
||||
rv = login("reviewer", "123456")
|
||||
post(rv, "/orders/%d/review" % oid, {"comment": "同意"})
|
||||
|
||||
ap = login("approver", "123456")
|
||||
post(ap, "/orders/%d/approve" % oid, {"comment": "批准"})
|
||||
print(" 审核流程 ok")
|
||||
|
||||
_, data = get(ap, "/orders/%d/pdf" % oid)
|
||||
path = os.path.join(OUT, "%s.pdf" % tag)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
print(" PDF -> %s (%d bytes)" % (path, len(data)))
|
||||
|
||||
# 草稿水印版
|
||||
oid2 = make_order(maker, doc_type, 1, 2)
|
||||
_, data2 = get(maker, "/orders/%d/pdf" % oid2)
|
||||
p2 = os.path.join(OUT, "%s_draft.pdf" % tag)
|
||||
with open(p2, "wb") as f:
|
||||
f.write(data2)
|
||||
print(" 草稿 PDF -> %s (%d bytes)" % (p2, len(data2)))
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
run("purchase", "purchase", 3)
|
||||
run("outsourcing_30rows", "outsourcing", 30)
|
||||
print("ALL DONE")
|
||||
raise SystemExit(main(()))
|
||||
|
||||
@@ -5,7 +5,8 @@ import sys
|
||||
|
||||
import pymupdf
|
||||
|
||||
OUT = r"D:\2026-09-09-task-18\tools\out"
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'out')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def render(pdf, pages=(0,), dpi=110, prefix=None):
|
||||
@@ -20,6 +21,7 @@ def render(pdf, pages=(0,), dpi=110, prefix=None):
|
||||
pix.save(path)
|
||||
print(path)
|
||||
print("pages:", len(doc))
|
||||
doc.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+4
-12
@@ -1,13 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import subprocess
|
||||
import sys
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
PY = r"C:\Users\wps18\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
|
||||
CWD = r"D:\2026-09-09-task-18\po_app"
|
||||
|
||||
p = subprocess.run([PY, "-c", "import app; print('IMPORT OK')"], cwd=CWD,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=60)
|
||||
print("RC", p.returncode)
|
||||
print("STDOUT:", p.stdout[-4000:])
|
||||
print("STDERR:", p.stderr[-4000:])
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(('-k', 'pages')))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Portable isolated regression suite. No running server or live database needed."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(extra=()):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
return subprocess.call([sys.executable, '-m', 'pytest', str(root / 'tests'), '-q', *extra], cwd=root)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -1,100 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模拟:经理批准也需先加章;批准通过后 PDF 上审核章与批准章并排。
|
||||
|
||||
用法:python test_approve_stamp.py 8099
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:%s" % (sys.argv[1] if len(sys.argv) > 1 else "8099")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "out")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(label, cond, extra=""):
|
||||
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
|
||||
if not cond:
|
||||
FAILS.append(label)
|
||||
|
||||
|
||||
def sess(u, p):
|
||||
s = requests.Session()
|
||||
s.post(BASE + "/login", data={"username": u, "password": p})
|
||||
return s
|
||||
|
||||
|
||||
def flash_of(h):
|
||||
return " | ".join(re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||||
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>', h, re.S))
|
||||
|
||||
|
||||
def dbq(sql, a=()):
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute(sql, a)
|
||||
r = cur.fetchone()
|
||||
con.close()
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
admin = sess("admin", "admin123")
|
||||
r = admin.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
oid = int(re.search(r"/orders/(\d+)/edit", r.url).group(1))
|
||||
admin.post(BASE + "/orders/%d" % oid, data={
|
||||
"supplier_name": u"测试供应商",
|
||||
"item_code": ["M-1001"], "item_spec": [u"测试物料"], "item_unit": [u"件"],
|
||||
"item_qty": ["10"], "item_price": ["12.5"], "item_date": [""], "item_remark": [""],
|
||||
})
|
||||
admin.post(BASE + "/orders/%d/submit" % oid, data={"comment": u"模拟"})
|
||||
|
||||
print("\n[1] 主管审核(加章)")
|
||||
rv = sess("reviewer", "123456")
|
||||
rv.post(BASE + "/orders/%d/review" % oid, data={"comment": u"ok", "stamped": "1"})
|
||||
check("审核通过", dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] == "pending_approval")
|
||||
|
||||
print("\n[2] 经理批准:未加章应被拒绝")
|
||||
ap = sess("approver", "123456")
|
||||
h = ap.post(BASE + "/orders/%d/approve" % oid, data={"comment": u"直接批", "stamped": "0"}).text
|
||||
check("未加章 → 提示", u"请先加盖批准章" in flash_of(h), flash_of(h))
|
||||
check("状态未变", dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] == "pending_approval")
|
||||
|
||||
print("\n[3] 加章后批准")
|
||||
ap.post(BASE + "/orders/%d/approve" % oid, data={"comment": u"同意", "stamped": "1"})
|
||||
row = dbq("SELECT status, approved_by FROM orders WHERE id=?", (oid,))
|
||||
check("已批准", row[0] == "approved", row)
|
||||
|
||||
print("\n[4] 导出 PDF(应含两枚章)")
|
||||
b = admin.get(BASE + "/orders/%d/pdf" % oid).content
|
||||
p = os.path.join(OUT, "seal_both.pdf")
|
||||
open(p, "wb").write(b)
|
||||
check("PDF 生成", len(b) > 10000, "%d bytes" % len(b))
|
||||
try:
|
||||
import pymupdf
|
||||
doc = pymupdf.open(p)
|
||||
doc[-1].get_pixmap(dpi=130).save(os.path.join(OUT, "seal_both.png"))
|
||||
print(" 预览:", os.path.join(OUT, "seal_both.png"), "%d 页" % doc.page_count)
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
print(" 渲染失败:", e)
|
||||
|
||||
print("\n[5] 清理")
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
|
||||
cur.execute("DELETE FROM orders WHERE id=?", (oid,))
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" 已删除订单 %d" % oid)
|
||||
print("\n" + ("全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'full_flow')))
|
||||
|
||||
+3
-206
@@ -1,208 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""批量导入 + 需方预置 + PO 号规则 E2E 测试。
|
||||
|
||||
用法:先启动服务(python app.py 8099),再运行本脚本。
|
||||
覆盖:物料/供应商导入的 新增 / 更新 / 跳过 / 失败 四条路径,
|
||||
模板下载,PO 号 4 位规则,以及旧格式单号的兼容解析。
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import openpyxl
|
||||
import requests
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
|
||||
BASE = "http://127.0.0.1:%s" % PORT
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "out")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
|
||||
|
||||
s = requests.Session()
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(label, cond, extra=""):
|
||||
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
|
||||
if not cond:
|
||||
FAILS.append(label)
|
||||
|
||||
|
||||
def login():
|
||||
r = s.post(BASE + "/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
allow_redirects=False)
|
||||
return r.status_code in (200, 302)
|
||||
|
||||
|
||||
def make_material_xlsx(path):
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append([u"物料代码", u"物料规格", u"单位", u"默认含税单价"])
|
||||
ws.append(["M-9001", u"测试新增物料A", u"件", 12.5]) # 新增
|
||||
ws.append(["M-9002", u"测试新增物料B", u"台", 88.0]) # 新增
|
||||
ws.append(["M-1001", u"铝合金支架(已存在-应被更新)", u"件", 99.9]) # 已存在
|
||||
ws.append(["", u"缺代码应失败", u"件", 1.0]) # 失败
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def make_supplier_xlsx(path):
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号",
|
||||
u"开户银行", u"银行帐号"])
|
||||
ws.append(["S901", u"测试新增供应商A", u"王经理", "13700000001",
|
||||
u"月结30天", "91320500TEST001", u"工商银行苏州分行", "1111 2222 3333"])
|
||||
ws.append(["S001", u"苏州精锐五金制品有限公司", u"周经理(已更新)", "13800000001",
|
||||
u"月结60天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行",
|
||||
"4562 1088 0012 3456"]) # 已存在
|
||||
ws.append(["S902", u"", u"缺名称", "13900000009", u"", u"", u"", u""]) # 失败
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def post_import(url, path, mode):
|
||||
with open(path, "rb") as f:
|
||||
return s.post(url,
|
||||
files={"file": (os.path.basename(path), f,
|
||||
"application/vnd.openxmlformats-officedocument."
|
||||
"spreadsheetml.sheet")},
|
||||
data={"mode": mode})
|
||||
|
||||
|
||||
def flash_texts(html):
|
||||
out = []
|
||||
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>', html, re.S):
|
||||
out.append(re.sub(r"<[^>]+>", "", m.group(1)).strip())
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("登录:", "OK" if login() else "FAIL")
|
||||
# 预清理:让脚本可重复运行(上次残留的测试数据不影响本次断言)
|
||||
try:
|
||||
today0 = datetime.datetime.now().strftime("%Y%m%d")
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("UPDATE materials SET spec=?, unit=?, price=? WHERE code=?",
|
||||
(u"铝合金支架 6061-T6 200×80×10mm", u"件", 38.5, "M-1001"))
|
||||
cur.execute("UPDATE suppliers SET contact=?, payment_terms=? WHERE code=?",
|
||||
(u"周经理", u"月结30天", "S001"))
|
||||
cur.execute("DELETE FROM materials WHERE code IN ('M-9001','M-9002')")
|
||||
cur.execute("DELETE FROM suppliers WHERE code IN ('S901','S902')")
|
||||
for p in ("PO", "WO"):
|
||||
cur.execute("DELETE FROM order_items WHERE order_id IN "
|
||||
"(SELECT id FROM orders WHERE order_no LIKE ?)",
|
||||
(p + today0 + "%",))
|
||||
cur.execute("DELETE FROM orders WHERE order_no LIKE ?", (p + today0 + "%",))
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception as e:
|
||||
print("预清理失败:", e)
|
||||
|
||||
mp = os.path.join(OUT, "materials_test.xlsx")
|
||||
sp = os.path.join(OUT, "suppliers_test.xlsx")
|
||||
make_material_xlsx(mp)
|
||||
make_supplier_xlsx(sp)
|
||||
|
||||
print("\n[1] 物料导入 update 模式")
|
||||
r = post_import(BASE + "/materials/import_excel", mp, "update")
|
||||
t = " | ".join(flash_texts(r.text))
|
||||
print(" flash:", t)
|
||||
check("新增2 / 更新1 / 失败1", u"新增 2" in t and u"更新 1" in t and u"失败 1" in t)
|
||||
|
||||
print("\n[2] 供应商导入 update 模式")
|
||||
r = post_import(BASE + "/suppliers/import_excel", sp, "update")
|
||||
t = " | ".join(flash_texts(r.text))
|
||||
print(" flash:", t)
|
||||
check("新增1 / 更新1 / 失败1", u"新增 1" in t and u"更新 1" in t and u"失败 1" in t)
|
||||
|
||||
print("\n[3] 再导一次,skip 模式(应全部跳过)")
|
||||
r = post_import(BASE + "/materials/import_excel", mp, "skip")
|
||||
t = " | ".join(flash_texts(r.text))
|
||||
print(" 物料 flash:", t)
|
||||
check("物料跳过3(M-9001/M-9002/M-1001 此时都已存在)", u"跳过 3" in t)
|
||||
r = post_import(BASE + "/suppliers/import_excel", sp, "skip")
|
||||
t = " | ".join(flash_texts(r.text))
|
||||
print(" 供应商 flash:", t)
|
||||
check("供应商跳过2(S901/S001 此时都已存在)", u"跳过 2" in t)
|
||||
|
||||
print("\n[4] 列表核对")
|
||||
html = s.get(BASE + "/materials").text
|
||||
check("M-9001 已新增", "M-9001" in html)
|
||||
check("M-1001 单价已更新为 99.90", "99.90" in html)
|
||||
html = s.get(BASE + "/suppliers").text
|
||||
check("S901 已新增", "S901" in html)
|
||||
check("联系人已更新为 周经理(已更新)", u"周经理(已更新)" in html)
|
||||
|
||||
print("\n[5] 模板下载")
|
||||
for u in ("/materials/import_template.xlsx", "/suppliers/import_template.xlsx"):
|
||||
rr = s.get(BASE + u)
|
||||
check(u, rr.status_code == 200 and len(rr.content) > 3000,
|
||||
"%s bytes" % len(rr.content))
|
||||
|
||||
print("\n[6] 新建采购单:PO 号规则 + 需方预置")
|
||||
created = []
|
||||
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
m = re.search(r"PO\d{12}", r.text)
|
||||
no = m.group(0) if m else ""
|
||||
created.append(no)
|
||||
print(" 单号:", no)
|
||||
today = datetime.datetime.now().strftime("%Y%m%d")
|
||||
check("格式为 PO+年月日+4位", bool(re.match(r"^PO\d{8}\d{4}$", no)))
|
||||
check("日期为今天 " + today, no.startswith("PO" + today))
|
||||
for k, v in (("our_contact", u"胡乃琛"), ("our_phone", u"13913172516"),
|
||||
("our_tax_no", u"91320507MA1WW8D15D"), ("tax_rate", u"13%")):
|
||||
check("预置 %s = %s" % (k, v), v in r.text)
|
||||
|
||||
print("\n[7] 旧格式单号兼容(插入带横杠的 PO%s-0007)" % today)
|
||||
legacy = "PO%s-0007" % today
|
||||
try:
|
||||
con = sqlite3.connect(DB)
|
||||
con.execute("INSERT INTO orders (order_no, doc_type, status, order_date) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(legacy, "purchase", "draft",
|
||||
datetime.datetime.now().strftime("%Y-%m-%d")))
|
||||
con.commit()
|
||||
con.close()
|
||||
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
m2 = re.search(r"PO\d{12}", r.text)
|
||||
got = m2.group(0) if m2 else ""
|
||||
print(" 新单号:", got, " 期望: PO%s0008" % today)
|
||||
check("旧格式未导致序号重置", got == "PO%s0008" % today)
|
||||
created.extend([got, legacy])
|
||||
except Exception as e:
|
||||
print(" 跳过,原因:", e)
|
||||
|
||||
print("\n[8] 清理测试数据(还原被更新的既有记录)")
|
||||
try:
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("UPDATE materials SET spec=?, unit=?, price=? WHERE code=?",
|
||||
(u"铝合金支架 6061-T6 200×80×10mm", u"件", 38.5, "M-1001"))
|
||||
cur.execute("UPDATE suppliers SET contact=?, payment_terms=? WHERE code=?",
|
||||
(u"周经理", u"月结30天", "S001"))
|
||||
cur.execute("DELETE FROM materials WHERE code IN ('M-9001','M-9002')")
|
||||
cur.execute("DELETE FROM suppliers WHERE code IN ('S901','S902')")
|
||||
for no_ in [x for x in created if x]:
|
||||
cur.execute("DELETE FROM order_items WHERE order_id IN "
|
||||
"(SELECT id FROM orders WHERE order_no=?)", (no_,))
|
||||
cur.execute("DELETE FROM orders WHERE order_no=?", (no_,))
|
||||
con.commit()
|
||||
cur.execute("SELECT COUNT(*) FROM materials WHERE code LIKE 'M-900%'")
|
||||
n1 = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM suppliers WHERE code IN ('S901','S902')")
|
||||
n2 = cur.fetchone()[0]
|
||||
con.close()
|
||||
check("测试物料已清除", n1 == 0)
|
||||
check("测试供应商已清除", n2 == 0)
|
||||
except Exception as e:
|
||||
print(" 清理失败:", e)
|
||||
|
||||
print("\n" + ("全部通过" if not FAILS else "失败项: " + "; ".join(FAILS)))
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'xls_native')))
|
||||
|
||||
+3
-117
@@ -1,119 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""编辑功能 + 明细行模板 冒烟测试。
|
||||
|
||||
覆盖:
|
||||
1) 新建订单(0 条明细,正是"点添加一行没反应"的场景)页面必须带 rowTpl 模板行
|
||||
2) 物料列表编辑:POST /materials 带 id 能改库
|
||||
3) 供应商列表编辑:POST /suppliers 带 id 能改库
|
||||
|
||||
用法:先启动服务(python app.py 8099),再 python test_edit_row.py 8099
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
|
||||
BASE = "http://127.0.0.1:%s" % PORT
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
|
||||
|
||||
s = requests.Session()
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(label, cond, extra=""):
|
||||
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
|
||||
if not cond:
|
||||
FAILS.append(label)
|
||||
|
||||
|
||||
def main():
|
||||
r = s.post(BASE + "/login", data={"username": "admin", "password": "admin123"})
|
||||
check("登录", r.status_code == 200, r.status_code)
|
||||
|
||||
print("\n[1] 新建订单(0 明细)—— 明细行模板")
|
||||
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
html = r.text
|
||||
no = ""
|
||||
m = re.search(r"PO\d{12}", html)
|
||||
if m:
|
||||
no = m.group(0)
|
||||
check("新建订单页含 rowTpl 模板", 'id="rowTpl"' in html)
|
||||
tpl = re.search(r'<template id="rowTpl">(.*?)</template>', html, re.S)
|
||||
check("模板内含 tr 行", bool(tpl) and "<tr>" in tpl.group(1))
|
||||
check("模板含 item_code 输入框", bool(tpl) and 'name="item_code"' in tpl.group(1))
|
||||
check("模板含 matpick 下拉", bool(tpl) and "matpick" in tpl.group(1))
|
||||
n_code = html.count('name="item_code"')
|
||||
check("当前确实 0 条明细(模板是唯一来源)", n_code == 1,
|
||||
"item_code 出现 %d 次" % n_code)
|
||||
|
||||
print("\n[2] 物料列表编辑")
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT id FROM materials WHERE code='M-1002'")
|
||||
row = cur.fetchone()
|
||||
con.close()
|
||||
if not row:
|
||||
check("找到物料 M-1002", False)
|
||||
else:
|
||||
mid = row[0]
|
||||
r = s.post(BASE + "/materials",
|
||||
data={"id": mid, "code": "M-1002", "spec": u"步进电机(已编辑)",
|
||||
"unit": u"台", "price": "299.5"})
|
||||
check("物料编辑请求成功", r.status_code == 200, r.status_code)
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT spec, unit, price FROM materials WHERE id=?", (mid,))
|
||||
got = cur.fetchone()
|
||||
check("物料已更新", got == (u"步进电机(已编辑)", u"台", 299.5), got)
|
||||
cur.execute("UPDATE materials SET spec=?, price=? WHERE id=?",
|
||||
(u"步进电机 57BYG250 2相", 265.0, mid))
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" 已还原 M-1002")
|
||||
|
||||
print("\n[3] 供应商列表编辑")
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT id FROM suppliers WHERE code='S002'")
|
||||
row = cur.fetchone()
|
||||
con.close()
|
||||
if not row:
|
||||
check("找到供应商 S002", False)
|
||||
else:
|
||||
sid = row[0]
|
||||
r = s.post(BASE + "/suppliers",
|
||||
data={"id": sid, "code": "S002", "name": u"昆山宏远电子科技有限公司",
|
||||
"contact": u"陈主管(已编辑)", "phone": "13900000002",
|
||||
"payment_terms": u"货到付款", "tax_no": "91320583MA2XXXXXXX",
|
||||
"bank_name": u"建设银行昆山分行",
|
||||
"bank_account": "3220 1988 2233 4455"})
|
||||
check("供应商编辑请求成功", r.status_code == 200, r.status_code)
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT contact, payment_terms FROM suppliers WHERE id=?", (sid,))
|
||||
got = cur.fetchone()
|
||||
check("供应商已更新", got == (u"陈主管(已编辑)", u"货到付款"), got)
|
||||
cur.execute("UPDATE suppliers SET contact=? WHERE id=?", (u"陈主管", sid))
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" 已还原 S002")
|
||||
|
||||
print("\n[4] 清理测试订单 " + no)
|
||||
if no:
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("DELETE FROM order_items WHERE order_id IN "
|
||||
"(SELECT id FROM orders WHERE order_no=?)", (no,))
|
||||
cur.execute("DELETE FROM orders WHERE order_no=?", (no,))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
print("\n" + ("全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'money_and_save')))
|
||||
|
||||
+3
-71
@@ -1,73 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试 Excel 明细导入接口(multipart 上传)。"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import openpyxl
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
OUT = r"D:\2026-09-09-task-18\tools\out"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def new_session():
|
||||
import http.cookiejar
|
||||
cj = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
|
||||
def post_form(op, path, data):
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
r = op.open(urllib.request.Request(BASE + path, data=body), timeout=30)
|
||||
return r.geturl(), r.read()
|
||||
|
||||
|
||||
def multipart_upload(op, path, field, filename, content, extra=None):
|
||||
boundary = "----PoTestBoundary1234"
|
||||
buf = io.BytesIO()
|
||||
for k, v in (extra or {}).items():
|
||||
buf.write(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
|
||||
% (boundary, k, v)).encode("utf-8"))
|
||||
buf.write(("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n"
|
||||
"Content-Type: application/octet-stream\r\n\r\n" % (boundary, field, filename))
|
||||
.encode("utf-8"))
|
||||
buf.write(content)
|
||||
buf.write(("\r\n--%s--\r\n" % boundary).encode())
|
||||
req = urllib.request.Request(BASE + path, data=buf.getvalue(), method="POST")
|
||||
req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
|
||||
r = op.open(req, timeout=30)
|
||||
return r.geturl(), r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def main():
|
||||
op = new_session()
|
||||
post_form(op, "/login", {"username": "maker", "password": "123456"})
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append([u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价", u"交期", u"备注"])
|
||||
ws.append([u"X-001", u"导入测试件 & <一>", u"件", 10, 99.9, "2026-10-01", u"首件"])
|
||||
ws.append([u"X-002", u"导入测试件 二", u"套", 3, 128.5, "2026-10-05", u""])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
xlsx = buf.getvalue()
|
||||
|
||||
url, _ = post_form(op, "/orders/new", {"doc_type": "purchase", "supplier_id": ""})
|
||||
oid = int(re.search(r"/orders/(\d+)", url).group(1))
|
||||
print("order id", oid)
|
||||
|
||||
url, html = multipart_upload(op, "/orders/%d/import_excel" % oid, "file",
|
||||
"test.xlsx", xlsx, {"mode": "replace"})
|
||||
ok = "已导入 2 行明细" in html
|
||||
print("import rows ok:", ok)
|
||||
ok2 = "99.90" in html or "99.9" in html
|
||||
print("price present:", ok2)
|
||||
assert ok and ok2
|
||||
print("IMPORT TEST PASSED")
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'chinese_filename')))
|
||||
|
||||
+3
-26
@@ -1,28 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""快速检查统计页与台账导出。"""
|
||||
import http.cookiejar
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
|
||||
|
||||
def main():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
op.open(urllib.request.Request(
|
||||
BASE + "/login",
|
||||
data=urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()),
|
||||
timeout=30)
|
||||
for path, expect in [("/stats", b"200"), ("/export/ledger", b"xlsx"),
|
||||
("/orders", b"200"), ("/suppliers", b"200"),
|
||||
("/materials", b"200"), ("/users", b"200"), ("/", b"200")]:
|
||||
r = op.open(BASE + path, timeout=30)
|
||||
data = r.read()
|
||||
print(path, r.status, len(data))
|
||||
assert r.status == 200, path
|
||||
print("PAGES OK")
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'pages')))
|
||||
|
||||
+3
-154
@@ -1,156 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模拟完整流程:停用/启用/删除 + 主管审核加章,并导出带章 PDF 渲染成 PNG。
|
||||
|
||||
用法:先启动服务(python app.py 8099),再 python test_stamp_toggle.py 8099
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
|
||||
BASE = "http://127.0.0.1:%s" % PORT
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "out")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(label, cond, extra=""):
|
||||
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
|
||||
if not cond:
|
||||
FAILS.append(label)
|
||||
|
||||
|
||||
def new_sess(u, p):
|
||||
s = requests.Session()
|
||||
r = s.post(BASE + "/login", data={"username": u, "password": p})
|
||||
assert r.status_code == 200, r.status_code
|
||||
return s
|
||||
|
||||
|
||||
def flash_of(html):
|
||||
return " | ".join(re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||||
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>',
|
||||
html, re.S))
|
||||
|
||||
|
||||
def row_html(html, key):
|
||||
"""取出包含指定关键字的那个 <tr>...</tr>。"""
|
||||
for m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.S):
|
||||
if key in m.group(1):
|
||||
return m.group(0)
|
||||
return ""
|
||||
|
||||
|
||||
def dbq(sql, args=()):
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute(sql, args)
|
||||
r = cur.fetchone()
|
||||
con.close()
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
admin = new_sess("admin", "admin123")
|
||||
|
||||
print("\n[1] 供应商:停用 → 整行置灰 → 启用 → 彻底删除")
|
||||
admin.post(BASE + "/suppliers",
|
||||
data={"code": "S-T1", "name": u"测试供应商T1", "contact": u"测试联系人"})
|
||||
sid = dbq("SELECT id FROM suppliers WHERE code='S-T1'")[0]
|
||||
h = admin.get(BASE + "/suppliers").text
|
||||
check("S-T1 已创建", "S-T1" in h)
|
||||
check("初始为启用态(未置灰)", "row-off" not in row_html(h, "S-T1"))
|
||||
admin.post(BASE + "/suppliers/%d/toggle" % sid)
|
||||
h = admin.get(BASE + "/suppliers").text
|
||||
check("停用后整行置灰", "row-off" in row_html(h, "S-T1"))
|
||||
check("停用后按钮变为『启用』", u"启用" in row_html(h, "S-T1"))
|
||||
check("停用后仍可编辑(保留入口)", "openEdit" in row_html(h, "S-T1"))
|
||||
admin.post(BASE + "/suppliers/%d/toggle" % sid)
|
||||
h = admin.get(BASE + "/suppliers").text
|
||||
check("启用后恢复(不再置灰)", "row-off" not in row_html(h, "S-T1"))
|
||||
admin.post(BASE + "/suppliers/%d/delete" % sid)
|
||||
h = admin.get(BASE + "/suppliers").text
|
||||
check("删除后从列表消失", "S-T1" not in h)
|
||||
|
||||
print("\n[2] 物料:停用 → 整行置灰 → 启用 → 彻底删除")
|
||||
admin.post(BASE + "/materials",
|
||||
data={"code": "M-T1", "spec": u"测试物料T1", "unit": u"件", "price": "9.9"})
|
||||
mid = dbq("SELECT id FROM materials WHERE code='M-T1'")[0]
|
||||
h = admin.get(BASE + "/materials").text
|
||||
check("M-T1 已创建", "M-T1" in h)
|
||||
check("初始为启用态(未置灰)", "row-off" not in row_html(h, "M-T1"))
|
||||
admin.post(BASE + "/materials/%d/toggle" % mid)
|
||||
h = admin.get(BASE + "/materials").text
|
||||
check("停用后整行置灰", "row-off" in row_html(h, "M-T1"))
|
||||
admin.post(BASE + "/materials/%d/toggle" % mid)
|
||||
h = admin.get(BASE + "/materials").text
|
||||
check("启用后恢复", "row-off" not in row_html(h, "M-T1"))
|
||||
admin.post(BASE + "/materials/%d/delete" % mid)
|
||||
h = admin.get(BASE + "/materials").text
|
||||
check("删除后从列表消失", "M-T1" not in h)
|
||||
|
||||
print("\n[3] 主管审核:确认前必须先加章")
|
||||
r = admin.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
m = re.search(r"/orders/(\d+)/edit", r.url)
|
||||
oid = int(m.group(1))
|
||||
admin.post(BASE + "/orders/%d" % oid, data={
|
||||
"supplier_name": u"测试供应商",
|
||||
"item_code": ["M-1001"], "item_spec": [u"测试物料"],
|
||||
"item_unit": [u"件"], "item_qty": ["10"], "item_price": ["12.5"],
|
||||
"item_date": [""], "item_remark": [""],
|
||||
})
|
||||
admin.post(BASE + "/orders/%d/submit" % oid, data={"comment": u"模拟提交"})
|
||||
st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0]
|
||||
check("订单已提交,状态待审核", st == "pending_review", st)
|
||||
|
||||
rv = new_sess("reviewer", "123456")
|
||||
r = rv.post(BASE + "/orders/%d/review" % oid,
|
||||
data={"comment": u"直接点确认试试", "stamped": "0"})
|
||||
check("未加章 → 被拒绝并提示", u"请先加盖审核章" in flash_of(r.text),
|
||||
flash_of(r.text))
|
||||
st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0]
|
||||
check("未加章时状态不变", st == "pending_review", st)
|
||||
|
||||
r = rv.post(BASE + "/orders/%d/review" % oid,
|
||||
data={"comment": u"已核对无误", "stamped": "1"})
|
||||
row = dbq("SELECT status, reviewed_by FROM orders WHERE id=?", (oid,))
|
||||
check("加章后审核通过", row[0] == "pending_approval", row)
|
||||
check("记录了审核人", bool(row[1]), row[1])
|
||||
|
||||
print("\n[4] 导出带章 PDF 并渲染预览")
|
||||
pdf_bytes = admin.get(BASE + "/orders/%d/pdf" % oid).content
|
||||
pdf_path = os.path.join(OUT, "seal_review.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(pdf_bytes)
|
||||
check("PDF 已生成", len(pdf_bytes) > 10000, "%d bytes" % len(pdf_bytes))
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(pdf_path)
|
||||
pix = doc[-1].get_pixmap(dpi=130)
|
||||
png_path = os.path.join(OUT, "seal_review.png")
|
||||
pix.save(png_path)
|
||||
print(" 渲染预览:", png_path, "共 %d 页" % doc.page_count)
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
print(" 渲染预览失败:", e)
|
||||
|
||||
print("\n[5] 清理测试订单")
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
|
||||
cur.execute("DELETE FROM orders WHERE id=?", (oid,))
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" 已删除订单 %d" % oid)
|
||||
|
||||
print("\n" + ("模拟全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
|
||||
|
||||
"""Compatibility entry point; tests only use temporary databases."""
|
||||
from run_tests import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main(('-k', 'full_flow')))
|
||||
|
||||
+37
-75
@@ -1,82 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""打包后 EXE 冒烟测试。
|
||||
|
||||
确认这些确实进了包:批量导入入口与模板、列表编辑入口、弹窗样式,
|
||||
以及「新建订单明细为空时仍能添加行」所依赖的 rowTpl 模板。
|
||||
|
||||
用法:python verify_exe.py 8080 [数据库路径]
|
||||
不传数据库路径时默认 po_app/dist/instance/po.db
|
||||
"""
|
||||
import os
|
||||
"""Read-only page/asset checks for a running packaged app; never writes order data."""
|
||||
import argparse
|
||||
import getpass
|
||||
import http.cookiejar
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:%s" % (sys.argv[1] if len(sys.argv) > 1 else "8080")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DB = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
|
||||
os.path.dirname(HERE), "po_app", "dist", "instance", "po.db")
|
||||
|
||||
s = requests.Session()
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(label, cond, extra=""):
|
||||
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
|
||||
if not cond:
|
||||
FAILS.append(label)
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
r = s.post(BASE + "/login", data={"username": "admin", "password": "admin123"})
|
||||
check("登录", r.status_code == 200, r.status_code)
|
||||
|
||||
# 注意:url_for() 会被 Jinja 渲染成实际路径,页面里不会出现函数名
|
||||
for path, marks in (
|
||||
("/materials", [u"批量导入 Excel", "matImpModal", "/materials/import_excel",
|
||||
"/materials/import_template.xlsx", "openEdit", "editModal",
|
||||
"/toggle", "/delete", u"停用", u"删除"]),
|
||||
("/suppliers", [u"批量导入 Excel", "supImpModal", "/suppliers/import_excel",
|
||||
"/suppliers/import_template.xlsx", "openEdit", "editModal",
|
||||
"/toggle", "/delete", u"停用", u"删除"]),
|
||||
):
|
||||
h = s.get(BASE + path).text
|
||||
missing = [m for m in marks if m not in h]
|
||||
check("%s 含导入/编辑/停用删除/置灰" % path, not missing, missing)
|
||||
|
||||
css = s.get(BASE + "/static/style.css").text
|
||||
check("style.css 含弹窗样式", "modal-mask" in css and "modal-box" in css)
|
||||
|
||||
for u in ("/materials/import_template.xlsx", "/suppliers/import_template.xlsx"):
|
||||
r = s.get(BASE + u)
|
||||
check("模板下载 %s" % u, r.status_code == 200 and len(r.content) > 3000,
|
||||
"%s bytes" % len(r.content))
|
||||
|
||||
# 新建订单:明细为空时必须仍能添加行(依赖 rowTpl 模板存在)
|
||||
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
|
||||
h = r.text
|
||||
no = ""
|
||||
m = re.search(r"PO\d{12}", h)
|
||||
if m:
|
||||
no = m.group(0)
|
||||
check("新建订单页含 rowTpl 明细行模板", 'id="rowTpl"' in h)
|
||||
tpl = re.search(r'<template id="rowTpl">(.*?)</template>', h, re.S)
|
||||
check("rowTpl 内含完整空行", bool(tpl) and 'name="item_code"' in tpl.group(1))
|
||||
|
||||
if no and os.path.exists(DB):
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
cur.execute("DELETE FROM order_items WHERE order_id IN "
|
||||
"(SELECT id FROM orders WHERE order_no=?)", (no,))
|
||||
cur.execute("DELETE FROM orders WHERE order_no=?", (no,))
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" 已清理测试订单 %s" % no)
|
||||
|
||||
print("\n" + ("打包版冒烟全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--url',default='http://127.0.0.1:8080')
|
||||
parser.add_argument('--username',required=True,help='Use an initialized admin account')
|
||||
args=parser.parse_args()
|
||||
base=args.url.rstrip('/')
|
||||
opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
def get(path):
|
||||
with opener.open(base+path,timeout=30) as response:
|
||||
return response.geturl(),response.read()
|
||||
_,body=get('/login')
|
||||
match=re.search(r'name="csrf_token" value="([^"]+)"',body.decode('utf-8'))
|
||||
if not match:
|
||||
raise SystemExit('Complete first-time setup before running this check.')
|
||||
fields={'username':args.username,'password':getpass.getpass('Password: '),'csrf_token':match[1]}
|
||||
with opener.open(urllib.request.Request(base+'/login',data=urllib.parse.urlencode(fields).encode()),timeout=30) as response:
|
||||
if response.geturl().rstrip('/') != base:
|
||||
raise SystemExit('Login failed or password change is required.')
|
||||
response.read()
|
||||
for path,marker in [('/orders/new',b'request_key'),('/users',b'username'),('/materials',b'matImpModal'),
|
||||
('/suppliers',b'supImpModal'),('/static/money.js',b'lineCents'),('/static/style.css',b'modal-mask')]:
|
||||
url,body=get(path)
|
||||
if url != base+path or marker not in body:
|
||||
raise SystemExit('Failed page/asset check: '+path)
|
||||
print('PASS',path)
|
||||
for path in ['/materials/import_template.xlsx','/suppliers/import_template.xlsx','/import_template.xlsx']:
|
||||
url,body=get(path)
|
||||
if url != base+path or not body.startswith(b'PK'):
|
||||
raise SystemExit('Failed workbook check: '+path)
|
||||
print('PASS',path)
|
||||
print('Packaged page/asset checks passed; order data was not modified.')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user