init: 博特维采购订单系统 v1.0 源码

This commit is contained in:
star
2026-09-10 16:18:15 +08:00
commit 6327ef7641
35 changed files with 4029 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""Dump .xls (BIFF) content: sheets, merged cells, all non-empty cell values."""
import sys
import xlrd
def dump(path):
print("=" * 100)
print("FILE:", path)
try:
bk = xlrd.open_workbook(path, formatting_info=True)
fi = True
except Exception as e:
print(" [formatting_info unavailable: %s]" % e)
bk = xlrd.open_workbook(path)
fi = False
print("sheets:", bk.sheet_names(), " nsheets:", bk.nsheets)
for sh in bk.sheets():
print("-" * 100)
print("SHEET: %s (%d rows x %d cols)" % (sh.name, sh.nrows, sh.ncols))
if fi:
try:
merged = sh.merged_cells
if merged:
print("MERGED:", merged)
except Exception:
pass
for r in range(sh.nrows):
row_vals = []
for c in range(sh.ncols):
v = sh.cell_value(r, c)
if isinstance(v, float) and v == int(v):
v = int(v)
s = str(v).strip()
if s:
row_vals.append("%s%d=%s" % (chr(65 + c) if c < 26 else "c" + str(c), r + 1, s))
if row_vals:
print(" | ".join(row_vals))
for p in sys.argv[1:]:
dump(p)
+99
View File
@@ -0,0 +1,99 @@
# -*- 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)))
if __name__ == "__main__":
run("purchase", "purchase", 3)
run("outsourcing_30rows", "outsourcing", 30)
print("ALL DONE")
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Init local git repo + commit source (respecting .gitignore) using dulwich.
No network/credentials needed. Push happens separately once remote URL+token are known.
"""
import os, sys
from dulwich import porcelain
from dulwich.repo import Repo
ROOT = r"D:\2026-09-09-task-18"
GITDIR = os.path.join(ROOT, ".git")
# 1) init (skip if already a repo)
if os.path.exists(GITDIR):
print("repo already exists, reusing")
repo = Repo(GITDIR)
else:
repo = porcelain.init(ROOT)
print("repo initialized at", GITDIR)
# 2) stage everything; dulwich add honors .gitignore via ignore manager
porcelain.add(repo, [ROOT])
print("staged files (count):", len(repo.open_index()))
# 3) show what got staged (verify no db / dist / backups)
staged = list(repo.open_index())
bad = [p for p in staged if
p.endswith(b".db") or b"\\dist\\" in p or p.startswith(b"dist/") or
b"dist_backup" in p or b"\\instance\\" in p or b"/instance/" in p or
p.startswith(b".workbuddy")]
print("SUSPECT_STAGED:", [p.decode("utf-8","replace") for p in bad])
if bad:
print("ABORT: ignored files would be committed; fix .gitignore")
sys.exit(2)
# 4) commit (with identity)
porcelain.commit(repo,
message=b"init: \xe5\x8d\x9a\xe7\x89\xb9\xe7\xbb\xb4\xe9\x87\x87\xe8\xb4\xad\xe8\xae\xa2\xe5\x8d\x95\xe7\xb3\xbb\xe7\xbb\x9f v1.0 \xe6\xba\x90\xe7\xa0\x81",
author=b"star <star@btw-ocr.com>",
committer=b"star <star@btw-ocr.com>")
print("COMMITTED. HEAD:", repo.head().decode())
# 5) report tracked file list
tracked = []
for e in repo.open_index().iteritems():
tracked.append(e[0].decode("utf-8","replace"))
print("TRACKED_FILES:")
for f in sorted(tracked):
print(" ", f)
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""把 PDF 指定页渲染成 PNG,用于版式校验。"""
import os
import sys
import pymupdf
OUT = r"D:\2026-09-09-task-18\tools\out"
def render(pdf, pages=(0,), dpi=110, prefix=None):
doc = pymupdf.open(pdf)
prefix = prefix or os.path.splitext(os.path.basename(pdf))[0]
for p in pages:
if p >= len(doc):
continue
page = doc[p]
pix = page.get_pixmap(dpi=dpi)
path = os.path.join(OUT, "%s_p%d.png" % (prefix, p + 1))
pix.save(path)
print(path)
print("pages:", len(doc))
if __name__ == "__main__":
name = sys.argv[1]
pages = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0]
render(os.path.join(OUT, name), pages)
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
import subprocess
import sys
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:])
+100
View File
@@ -0,0 +1,100 @@
# -*- 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)))
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
# -*- 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)))
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
# -*- 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)))
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
# -*- 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")
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
# -*- 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")
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
# -*- 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)))
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""打包后 EXE 冒烟测试。
确认这些确实进了包:批量导入入口与模板、列表编辑入口、弹窗样式,
以及「新建订单明细为空时仍能添加行」所依赖的 rowTpl 模板。
用法:python verify_exe.py 8080 [数据库路径]
不传数据库路径时默认 po_app/dist/instance/po.db
"""
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 "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)
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)))
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Dump .xls layout: row heights, col widths, print setup, print area, formulas."""
import sys
import xlrd
def dump(path):
print("=" * 90)
print("FILE:", path)
bk = xlrd.open_workbook(path, formatting_info=True)
try:
names = {k: (v.name, getattr(v, 'result', None)) for k, v in list(bk.name_map.items())}
print("NAMES:", names)
except Exception as e:
print("NAMES err", e)
for sh in bk.sheets():
print("-" * 90)
print("SHEET", sh.name, "nrows", sh.nrows, "ncols", sh.ncols)
print("defcolwidth", sh.defcolwidth, "standardwidth", getattr(sh, 'standardwidth', None))
print("colwidths:", [(c, sh.colinfo_map[c].width if c in sh.colinfo_map else None) for c in range(sh.ncols)])
print("rowheights:", [(r + 1, sh.rowinfo_map[r].height if r in sh.rowinfo_map else None) for r in range(sh.nrows)])
try:
pa = sh.phony_dim_rows
print("phony_dim_rows", pa)
except Exception:
pass
for attr in ("print_area", "print_titles"):
print(attr, getattr(bk, attr, None))
for p in sys.argv[1:]:
dump(p)