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

增加部分付款和部分交货状态、工作台筛选、权限校验、跟进记录及 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
+2
View File
@@ -0,0 +1,2 @@
# Preserve PDF bytes and prevent text diffs or line-ending conversion.
*.pdf binary
+3
View File
@@ -26,6 +26,9 @@ exports/
# ===== 测试产生的样张 =====
tools/out/
# ===== PDF 排版与校验临时文件 =====
/tmp/pdfs/
# ===== 编辑器 / 系统 =====
.vscode/
.idea/
+23 -2
View File
@@ -1,6 +1,6 @@
# 博特维采购订单管理系统
苏州博特维智能科技有限公司内部使用的采购单 / 委外生产订单系统。当前源码版本 v3,支持制单、单次审批、正式 PDF 归档、撤回、作废及变更重审。
苏州博特维智能科技有限公司内部使用的采购单 / 委外生产订单系统。当前源码版本 v4,支持制单、单次审批、正式 PDF 归档、撤回、作废、变更重审,以及付款、收票和交货跟进。
## 启动
@@ -19,6 +19,24 @@ python po_app/app.py 8080
**已有数据升级前,请阅读 [运行与升级说明](运行与升级说明.md)。** 程序会先备份旧数据库再执行增量迁移;重复单号会阻止升级,不能自动给历史单据改号。
完整操作步骤见 [v4 使用与升级手册(PDF,16 页)](output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf)。第 10-12 页包含企业微信建群、创建机器人、获取 Webhook、系统配置、手机审批验收及问题排查。手册维护方法见 [手册源文件说明](docs/manual/README.md)。
## Windows 单文件 EXE
在 Windows 64 位电脑上使用 Python 3.11 打包。在项目根目录的 PowerShell 中执行:
```powershell
py -3.11 -m venv tools/out/build-venv
tools/out/build-venv/Scripts/python.exe -m pip install -r requirements-build.txt
cd po_app
../tools/out/build-venv/Scripts/python.exe -m PyInstaller --noconfirm --distpath ../dist --workpath ../build 博特维采购订单系统_单文件版.spec
cd ..
```
产物为 `dist/博特维采购订单系统_单文件版.exe`,使用时无需安装 Python。将 EXE 放在可写的固定文件夹,双击启动;首次初始化口令位于 EXE 同目录的 `instance/setup.token`。默认自动打开浏览器,可设置 `PO_OPEN_BROWSER=0` 关闭自动打开。
安装开发验收依赖后,可执行 `python tools/browser_smoke.py --exe dist/博特维采购订单系统_单文件版.exe`。脚本在临时目录中复制并运行 EXE,验证页面、建单、手机尺寸审批、Excel 导入导出、带章 PDF 及重启后的归档一致性。EXE 测试不启用企业微信发送;测试数据和截图位于 `tools/out/ui-check-*`。
## 主要能力
- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。
@@ -33,8 +51,10 @@ python po_app/app.py 8080
- 用户编辑、重置密码、启用和停用,以及旧会话失效。
- 随机部署密钥、表单 CSRF 校验、请求独立事务、单号唯一约束、重复建单和旧页面操作保护。
- 按状态、供应商、月份统计以及 Excel 台账导出。
- 已审批订单的预付款标记、收票与交货跟进;审批员或管理员登记付款状态与日期,支持部分付款、部分交货。
- 工作台按需预付款、已收票待付款、未交货、已付款未交齐等情况统计并跳转筛选,台账同步导出跟进状态。
图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算、到货和付款跟踪不在当前版本中。
图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算和逐笔资金对账不在当前版本中。付款与交货按整张订单标注状态,部分完成的明细可填写在备注中。
## 项目结构
@@ -44,6 +64,7 @@ po_app/
security.py 会话、认证、请求事务、首次设置
order_routes.py 制单及审批操作、PDF 和版本查看
order_logic.py 权限、校验、金额、提交快照
order_tracking.py 付款、收票和交货跟进、工作台分类筛选
notifications.py 企业微信群通知、事务队列与后台发送
notification_routes.py 管理员通知配置、测试通知和重试
models.py 数据模型及固定精度字段
+15
View File
@@ -0,0 +1,15 @@
# 使用与升级手册维护
正式文件:[`output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf`](../../output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf)。
生成脚本为 `tools/build_user_manual.py`。`screenshots/` 内四张图片来自 v4 单文件 EXE 的隔离浏览器验收,仅含测试订单和示例配置,不包含真实业务数据或 Webhook。
在 Windows 上使用 Python 3.11、ReportLab 和 PyMuPDF 生成,中文字体使用系统自带的微软雅黑。项目构建依赖和开发验收依赖包含所需 Python 包。
```powershell
py -3.11 tools/build_user_manual.py
```
默认使用本目录保存的截图,也可通过 `--screenshots` 指定新一轮验收截图目录,或通过 `--output` 指定输出文件。截图变更后需核对脚本的裁切坐标。
脚本会检查总页数、章节书签及目录和官方参考链接。发布前仍须将 PDF 渲染为图片,逐页检查中文、表格、截图和分页;增加章节时同步更新正文页码引用。
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long
+15 -4
View File
@@ -14,6 +14,7 @@ from security import db, current_user, login_required, role_required, init_secur
from money import number, format_price
from order_logic import scope
from order_routes import register_order_routes
from order_tracking import TRACKING_FILTERS, filter_tracking, register_tracking_routes
from excel_io import read_rows, read_material_rows, read_supplier_rows, save_upload
from flask import abort
@@ -51,6 +52,7 @@ app.config['PO_UPLOADS'] = UPLOADS
init_security(app, Session, INSTANCE)
app.jinja_env.filters['price'] = format_price
register_order_routes(app)
register_tracking_routes(app)
from notification_routes import register_notification_routes
register_notification_routes(app)
@@ -74,8 +76,11 @@ def index():
counts = {s: scope(db.query(Order)).filter_by(status=s).count() for s in STATUS_TEXT}
month_total = (scope(db.query(func.sum(Order.total_amount)))
.filter(Order.status == "approved").scalar() or 0)
tracking_counts = {key: filter_tracking(scope(db.query(Order)), key).count() for key in TRACKING_FILTERS}
tracking_rows = scope(db.query(Order)).filter_by(status='approved').order_by(Order.id.desc()).limit(12).all()
return render_template("index.html", user=u, todo=todo, my_orders=my_orders,
counts=counts, month_total=month_total,
tracking_counts=tracking_counts, tracking_rows=tracking_rows,
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
@@ -103,6 +108,7 @@ def orders():
q = q.filter(Order.order_date <= date_to)
if u.role == "maker":
q = q.filter(Order.created_by == u.id)
q = filter_tracking(q, request.args.get('tracking', ''))
rows = q.order_by(Order.id.desc()).all()
return render_template("orders.html", user=u, rows=rows, status_text=STATUS_TEXT,
doc_types=DOC_TYPES, args=request.args)
@@ -479,13 +485,17 @@ def export_ledger():
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"采购单号", u"单据类型", u"供应商", u"采购日期", u"状态", u"金额",
u"制单人", u"审批人", u"创建时间"])
u"制单人", u"审批人", u"创建时间", '需预付款', '收票状态', '付款状态',
'付款日期', '交货状态', '采购跟进备注', '付款备注', '变更后待核对'])
for o in rows:
ws.append([o.order_no, o.doc_type_text, o.supplier_name, o.order_date,
o.status_text, o.total_amount, o.created_by_name,
o.approved_by,
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""])
for col, w in zip("ABCDEFGHI", (18, 14, 30, 12, 12, 12, 12, 12, 18)):
o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else "",
'是' if o.needs_prepayment else '否', o.invoice_status_text, o.payment_status_text,
o.payment_date, o.delivery_status_text, o.fulfillment_note, o.payment_note,
'、'.join(label for label, stale in [('采购跟进', o.fulfillment_stale), ('付款', o.payment_stale)] if stale)])
for col, w in zip("ABCDEFGHIJKLMNOPQ", (18, 14, 30, 12, 12, 12, 12, 12, 18, 12, 12, 12, 14, 12, 36, 36, 22)):
ws.column_dimensions[col].width = w
buf = io.BytesIO()
wb.save(buf)
@@ -528,7 +538,8 @@ if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
while port_busy(port) and port < 8120:
port += 1
if getattr(sys, "frozen", False) or os.environ.get("PO_OPEN_BROWSER") == "1":
open_browser = os.environ.get("PO_OPEN_BROWSER")
if open_browser == "1" or (getattr(sys, "frozen", False) and open_browser != "0"):
threading.Timer(1.5, lambda: webbrowser.open("http://127.0.0.1:%d" % port)).start()
print("=" * 56)
print(" 博特维采购订单管理系统已启动")
+23 -2
View File
@@ -14,7 +14,7 @@ def upgrade_database(path):
if 'orders' not in tables:
return
version = con.execute('PRAGMA user_version').fetchone()[0]
if version >= 3:
if version >= 4:
return
duplicates = con.execute('SELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) > 1').fetchall()
if duplicates:
@@ -26,14 +26,35 @@ def upgrade_database(path):
try:
if version < 2:
upgrade_fixed_amounts(con)
if version < 3:
merge_approval(con, tables)
con.execute('PRAGMA user_version=3')
add_tracking(con)
con.execute('PRAGMA user_version=4')
con.commit()
except Exception:
con.rollback()
raise
def add_tracking(con):
# Historical invoices, payments and deliveries are unknown until recorded.
additions = {
'needs_prepayment': 'BOOLEAN NOT NULL DEFAULT 0',
'invoice_status': "VARCHAR(20) NOT NULL DEFAULT 'unknown'",
'delivery_status': "VARCHAR(20) NOT NULL DEFAULT 'unknown'",
'payment_status': "VARCHAR(20) NOT NULL DEFAULT 'unknown'",
'payment_date': "VARCHAR(10) NOT NULL DEFAULT ''",
'fulfillment_note': "TEXT NOT NULL DEFAULT ''",
'payment_note': "TEXT NOT NULL DEFAULT ''",
'fulfillment_revision': 'INTEGER NOT NULL DEFAULT -1',
'payment_revision': 'INTEGER NOT NULL DEFAULT -1',
}
existing = {row[1] for row in con.execute('PRAGMA table_info(orders)')}
for name, definition in additions.items():
if name not in existing:
con.execute('ALTER TABLE orders ADD COLUMN %s %s' % (name, definition))
def upgrade_fixed_amounts(con):
additions = {
'users': {'session_version': 'INTEGER NOT NULL DEFAULT 1', 'must_change_password': 'BOOLEAN NOT NULL DEFAULT 1'},
+34 -1
View File
@@ -58,6 +58,10 @@ ROLE_TEXT = {
"approver": u"审批员",
}
INVOICE_STATUS = {'unknown': '未登记', 'pending': '未收票', 'received': '已收票'}
DELIVERY_STATUS = {'unknown': '未登记', 'pending': '未交货', 'partial': '部分交货', 'delivered': '已交货'}
PAYMENT_STATUS = {'unknown': '未登记', 'unpaid': '未付款', 'partial': '部分付款', 'paid': '已付款'}
class User(Base):
__tablename__ = "users"
@@ -135,6 +139,15 @@ class Order(Base):
approved_by_id = Column(Integer, ForeignKey('users.id'))
revision = Column(Integer, nullable=False, default=0)
row_version = Column(Integer, nullable=False, default=1)
needs_prepayment = Column(Boolean, nullable=False, default=False)
invoice_status = Column(String(20), nullable=False, default='pending')
delivery_status = Column(String(20), nullable=False, default='pending')
payment_status = Column(String(20), nullable=False, default='unpaid')
payment_date = Column(String(10), nullable=False, default='')
fulfillment_note = Column(Text, nullable=False, default='')
payment_note = Column(Text, nullable=False, default='')
fulfillment_revision = Column(Integer, nullable=False, default=-1)
payment_revision = Column(Integer, nullable=False, default=-1)
__mapper_args__ = {'version_id_col': row_version}
items = relationship("OrderItem", backref="order", cascade="all, delete-orphan",
@@ -151,6 +164,26 @@ class Order(Base):
def doc_type_text(self):
return DOC_TYPES.get(self.doc_type, self.doc_type)
@property
def invoice_status_text(self):
return INVOICE_STATUS.get(self.invoice_status, '未登记')
@property
def delivery_status_text(self):
return DELIVERY_STATUS.get(self.delivery_status, '未登记')
@property
def payment_status_text(self):
return PAYMENT_STATUS.get(self.payment_status, '未登记')
@property
def fulfillment_stale(self):
return self.fulfillment_revision >= 0 and self.fulfillment_revision != self.revision
@property
def payment_stale(self):
return self.payment_revision >= 0 and self.payment_revision != self.revision
class OrderItem(Base):
__tablename__ = "order_items"
@@ -244,5 +277,5 @@ def init_db(db_path):
Base.metadata.create_all(engine)
if not existed:
with engine.connect() as con:
con.exec_driver_sql('PRAGMA user_version=3')
con.exec_driver_sql('PRAGMA user_version=4')
return engine, sessionmaker(bind=engine)
+2
View File
@@ -14,6 +14,7 @@ from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_appro
next_order_no, recalc, remember_creation, save_form, snapshot, submit,
validate_order)
from pdf_export import build_pdf, seal_layout
from order_tracking import can_follow, can_record_payment
from security import current_user, db, login_required, role_required
@@ -88,6 +89,7 @@ def register_order_routes(app):
user, order = current_user(), get_order(oid)
return render_template('order_view.html', user=user, o=order, editable=can_edit(order, user),
may_approve=can_approve(order, user),
may_follow=can_follow(order, user), may_pay=can_record_payment(order, user),
seal=seal_layout(order.our_company) if can_approve(order, user) else None,
may_reject=can_reject(order, user), total_cn=amount_to_cn(order.total_amount),
status_text=STATUS_TEXT, role_text=ROLE_TEXT)
+163
View File
@@ -0,0 +1,163 @@
"""Internal order follow-up; approved documents stay frozen."""
from datetime import date
from flask import abort, flash, redirect, request, url_for
from sqlalchemy import and_, or_
from models import DELIVERY_STATUS, INVOICE_STATUS, PAYMENT_STATUS, Order
from order_logic import add_log, get_order, normalize_date
from security import current_user, db, login_required
TRACKING_FILTERS = {
'prepayment': '需预付款',
'invoice_due': '已收票待付款',
'unpaid': '未付款',
'partial_payment': '部分付款',
'paid': '已付款',
'undelivered': '未交货',
'partial_delivery': '部分交货',
'delivered': '已交货',
'paid_undelivered': '已付款未交齐',
'completed': '已付款且已交货',
'unrecorded': '跟进待登记',
'recheck': '变更后待核对',
}
def tracking_conditions():
current_fulfillment = or_(Order.fulfillment_revision < 0, Order.fulfillment_revision == Order.revision)
current_payment = or_(Order.payment_revision < 0, Order.payment_revision == Order.revision)
payment_due = and_(current_payment, Order.payment_status.in_(('unpaid', 'partial')))
delivery_open = and_(current_fulfillment, Order.delivery_status.in_(('pending', 'partial')))
return {
'prepayment': and_(current_fulfillment, Order.needs_prepayment.is_(True), payment_due),
'invoice_due': and_(current_fulfillment, Order.invoice_status == 'received', payment_due),
'unpaid': and_(current_payment, Order.payment_status == 'unpaid'),
'partial_payment': and_(current_payment, Order.payment_status == 'partial'),
'paid': and_(current_payment, Order.payment_status == 'paid'),
'undelivered': and_(current_fulfillment, Order.delivery_status == 'pending'),
'partial_delivery': and_(current_fulfillment, Order.delivery_status == 'partial'),
'delivered': and_(current_fulfillment, Order.delivery_status == 'delivered'),
'paid_undelivered': and_(current_payment, Order.payment_status == 'paid', delivery_open),
'completed': and_(current_payment, current_fulfillment, Order.payment_status == 'paid',
Order.delivery_status == 'delivered'),
'unrecorded': or_(Order.invoice_status == 'unknown', Order.delivery_status == 'unknown',
Order.payment_status == 'unknown'),
'recheck': or_(~current_fulfillment, ~current_payment),
}
def filter_tracking(query, key):
if not key:
return query
conditions = tracking_conditions()
if key not in conditions:
abort(400, description='无效的订单跟进筛选条件')
return query.filter(Order.status == 'approved', conditions[key])
def can_follow(order, user):
return (order.status == 'approved' and user.role in ('maker', 'admin')
and (user.role == 'admin' or order.created_by == user.id))
def can_record_payment(order, user):
return order.status == 'approved' and user.role in ('approver', 'admin')
def enum_value(form, key, choices):
value = form.get(key, '')
if value not in choices:
raise ValueError('请选择有效的跟进状态')
return value
def note_value(form, key):
value = form.get(key, '').strip()
if len(value) > 500:
raise ValueError('备注最多填写 500 个字符')
return value
def register_tracking_routes(app):
@app.context_processor
def tracking_context():
return {'INVOICE_STATUS': INVOICE_STATUS, 'DELIVERY_STATUS': DELIVERY_STATUS,
'PAYMENT_STATUS': PAYMENT_STATUS, 'TRACKING_FILTERS': TRACKING_FILTERS}
@app.post('/orders/<int:oid>/follow-up')
@login_required
def order_follow_up(oid):
order, user = get_order(oid, mutation=True), current_user()
if not can_follow(order, user):
abort(403)
if set(request.form).intersection(('payment_status', 'payment_date', 'payment_note')):
abort(403, description='付款状态须由审批员或管理员登记')
invoice = enum_value(request.form, 'invoice_status', INVOICE_STATUS)
delivery = enum_value(request.form, 'delivery_status', DELIVERY_STATUS)
prepayment = request.form.get('needs_prepayment', '0')
if prepayment not in ('0', '1'):
raise ValueError('预付款标记无效')
note = note_value(request.form, 'fulfillment_note')
reason = note_value(request.form, 'reason')
if ((order.invoice_status == 'received' and invoice != 'received') or
(order.delivery_status in ('partial', 'delivered') and
list(DELIVERY_STATUS).index(delivery) < list(DELIVERY_STATUS).index(order.delivery_status))):
if not reason:
raise ValueError('回退收票或交货状态时,请填写更正原因')
changes = []
values = {'needs_prepayment': prepayment == '1', 'invoice_status': invoice,
'delivery_status': delivery, 'fulfillment_note': note}
labels = {'needs_prepayment': ('预付款', {True: '需要', False: '不需要'}),
'invoice_status': ('收票', INVOICE_STATUS), 'delivery_status': ('交货', DELIVERY_STATUS)}
for key, value in values.items():
old = getattr(order, key)
if old != value:
label, choices = labels.get(key, ('跟进备注', {}))
changes.append('%s:%s → %s' % (label, choices.get(old, old or '空'), choices.get(value, value or '空')))
setattr(order, key, value)
if order.fulfillment_stale:
changes.append('已核对本次变更后的采购跟进状态')
if changes or order.fulfillment_revision != order.revision:
order.fulfillment_revision = order.revision
add_log(order, '更新采购跟进', user, ';'.join(changes + (['更正原因:' + reason] if reason else [])))
db.commit()
flash('采购跟进已保存', 'ok')
return redirect(url_for('order_view', oid=oid) + '#order-tracking')
@app.post('/orders/<int:oid>/payment')
@login_required
def order_payment(oid):
order, user = get_order(oid, mutation=True), current_user()
if not can_record_payment(order, user):
abort(403)
if set(request.form).intersection(('needs_prepayment', 'invoice_status', 'delivery_status', 'fulfillment_note')):
abort(403, description='采购跟进须在对应表单中登记')
status = enum_value(request.form, 'payment_status', PAYMENT_STATUS)
paid_date = normalize_date(request.form.get('payment_date'), '付款日期', required=status in ('partial', 'paid'))
if paid_date and paid_date > date.today().isoformat():
raise ValueError('实际付款日期不能晚于今天')
if status in ('unpaid', 'unknown'):
paid_date = ''
note = note_value(request.form, 'payment_note')
reason = note_value(request.form, 'reason')
if (order.payment_status in ('partial', 'paid') and
list(PAYMENT_STATUS).index(status) < list(PAYMENT_STATUS).index(order.payment_status) and not reason):
raise ValueError('回退付款状态时,请填写更正原因')
changes = []
for key, value, label in [('payment_status', status, '付款'), ('payment_date', paid_date, '付款日期'),
('payment_note', note, '付款备注')]:
old = getattr(order, key)
if old != value:
choices = PAYMENT_STATUS if key == 'payment_status' else {}
changes.append('%s:%s → %s' % (label, choices.get(old, old or '空'), choices.get(value, value or '空')))
setattr(order, key, value)
if order.payment_stale:
changes.append('已核对本次变更后的付款状态')
if changes or order.payment_revision != order.revision:
order.payment_revision = order.revision
add_log(order, '登记付款状态', user, ';'.join(changes + (['更正原因:' + reason] if reason else [])))
db.commit()
flash('付款状态已保存', 'ok')
return redirect(url_for('order_view', oid=oid) + '#order-tracking')
+12
View File
@@ -119,6 +119,16 @@ table.grid tr.row-off .btn { opacity: .8; }
.seal-code { font-family: Arial, "Helvetica Neue", sans-serif; font-weight: 400; }
.notification-wide { grid-column: 1 / -1; }
.tracking-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; padding: 8px 0 16px; }
.tracking-forms { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; }
.tracking-forms form { min-width: 0; padding-top: 4px; border-top: 1px solid #e2e6ea; }
.tracking-stat { color: #222; }
.tracking-stat:hover { border-color: #1668dc; text-decoration: none; background: #f7faff; }
.tracking-warning { color: #a35c00; font-size: 12px; margin-top: 4px; }
.badge.track-unknown { background: #eef0f2; color: #666; }
.badge.track-pending, .badge.track-unpaid { background: #fff4e0; color: #a35c00; }
.badge.track-partial { background: #e6f0ff; color: #14509b; }
.badge.track-received, .badge.track-delivered, .badge.track-paid { background: #e8f6ee; color: #12703f; }
.mobile-only { display: none; }
@media (max-width: 800px) {
.topbar { height: auto; min-height: 52px; flex-wrap: wrap; gap: 12px; padding: 12px 16px; }
@@ -131,6 +141,8 @@ table.grid tr.row-off .btn { opacity: .8; }
.form-grid, .form-grid.two { grid-template-columns: 1fr; }
.form-grid > [style*="grid-column"] { grid-column: auto !important; }
.stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.tracking-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tracking-forms { grid-template-columns: minmax(0, 1fr); }
table.grid { min-width: 680px; }
table.approval-flow { min-width: 0; }
.mobile-only { display: block; }
+1 -1
View File
@@ -35,6 +35,6 @@
{% endwith %}
{% block body %}{% endblock %}
</div>
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v3.0</footer>
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v4.0</footer>
</body>
</html>
+25
View File
@@ -8,6 +8,31 @@
<div class="stat"><div class="v">{{ '%.2f'|format(month_total) }}</div><div class="k">审批通过订单总额(元)</div></div>
</div>
<div class="card tracking-dashboard">
<div class="card-head"><h2>付款与交货概况</h2><a href="{{ url_for('orders', status='approved') }}">查看全部</a></div>
<p class="muted">统计当前可见、审批通过的订单。各类数量可能重叠;部分付款和部分交货按订单状态统计。</p>
<div class="stat-row">
{% for key in ['prepayment','invoice_due','unpaid','paid','undelivered','partial_delivery','paid_undelivered','completed'] %}
<a class="stat tracking-stat" data-tracking="{{ key }}" href="{{ url_for('orders', tracking=key) }}"><div class="v">{{ tracking_counts[key] }}</div><div class="k">{{ TRACKING_FILTERS[key] }}</div></a>
{% endfor %}
</div>
<div class="toolbar tracking-shortcuts">{% for key in ['partial_payment','delivered','unrecorded','recheck'] %}<a class="btn sm" data-tracking="{{ key }}" href="{{ url_for('orders', tracking=key) }}">{{ TRACKING_FILTERS[key] }}({{ tracking_counts[key] }})</a>{% endfor %}</div>
<h3>最近审批通过的订单</h3>
<table class="grid tracking-table">
<tr><th>订单 / 供应商</th><th class="num">订单金额</th><th>预付款</th><th>收票</th><th>付款</th><th>交货</th><th>操作</th></tr>
{% for o in tracking_rows %}
<tr>
<td><a href="{{ url_for('order_view', oid=o.id) }}#order-tracking">{{ o.order_no }}</a><div class="muted">{{ o.supplier_name }}</div>{% if o.fulfillment_stale or o.payment_stale %}<div class="tracking-warning">变更后待核对</div>{% endif %}</td>
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td><td>{{ '需要' if o.needs_prepayment else '—' }}</td>
<td><span class="badge track-{{ o.invoice_status }}">{{ o.invoice_status_text }}</span></td>
<td><span class="badge track-{{ o.payment_status }}">{{ o.payment_status_text }}</span></td>
<td><span class="badge track-{{ o.delivery_status }}">{{ o.delivery_status_text }}</span></td>
<td><a class="btn sm" href="{{ url_for('order_view', oid=o.id) }}#order-tracking">查看跟进</a></td>
</tr>
{% else %}<tr><td colspan="7" class="center muted">暂无审批通过的订单</td></tr>{% endfor %}
</table>
</div>
{% if todo %}
<div class="card">
<h2>待我处理({{ todo|length }})</h2>
+44
View File
@@ -0,0 +1,44 @@
<div class="card" id="order-tracking">
<h2>付款与交货跟进</h2>
<div class="tracking-summary">
<div><label>预付款安排</label><b>{{ '需提前付款' if o.needs_prepayment else '正常付款' }}</b></div>
<div><label>收票情况</label><span class="badge track-{{ o.invoice_status }}">{{ o.invoice_status_text }}</span></div>
<div><label>付款情况</label><span class="badge track-{{ o.payment_status }}">{{ o.payment_status_text }}</span>{% if o.payment_date %}<div class="muted">{{ o.payment_date }}</div>{% endif %}</div>
<div><label>交货情况</label><span class="badge track-{{ o.delivery_status }}">{{ o.delivery_status_text }}</span></div>
</div>
{% if o.fulfillment_stale or o.payment_stale %}
<p class="hint">订单内容已变更,以下记录来自变更前:{{ '采购跟进' if o.fulfillment_stale else '' }}{{ '、' if o.fulfillment_stale and o.payment_stale else '' }}{{ '付款情况' if o.payment_stale else '' }}。请对应经办人核对后保存确认。</p>
{% endif %}
{% if o.status != 'approved' %}<p class="muted">审批通过后可登记付款、收票和交货情况。已有跟进记录保留供查询。</p>{% endif %}
{% if o.fulfillment_note %}<p>采购跟进备注:{{ o.fulfillment_note }}</p>{% endif %}
{% if o.payment_note %}<p>付款备注:{{ o.payment_note }}</p>{% endif %}
<div class="tracking-forms">
{% if may_follow %}
<form id="follow-up-form" method="post" action="{{ url_for('order_follow_up', oid=o.id) }}">
<h3>制单员跟进</h3>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<div class="field"><label for="needs-prepayment">付款安排</label><select id="needs-prepayment" name="needs_prepayment"><option value="0" {{ 'selected' if not o.needs_prepayment else '' }}>正常付款</option><option value="1" {{ 'selected' if o.needs_prepayment else '' }}>需提前付款</option></select></div>
<div class="form-grid two">
<div class="field"><label for="invoice-status">收票情况</label><select id="invoice-status" name="invoice_status">{% for key, label in INVOICE_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.invoice_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
<div class="field"><label for="delivery-status">交货情况</label><select id="delivery-status" name="delivery_status">{% for key, label in DELIVERY_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.delivery_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
</div>
<div class="field"><label for="fulfillment-note">跟进备注(选填)</label><textarea id="fulfillment-note" name="fulfillment_note" maxlength="500" placeholder="例如:先付30%预付款,已收到第一批货">{{ o.fulfillment_note }}</textarea></div>
<div class="field"><label for="follow-up-reason">更正原因(回退状态时必填)</label><input id="follow-up-reason" name="reason" maxlength="500"></div>
<button class="btn primary">保存采购跟进</button>
</form>
{% endif %}
{% if may_pay %}
<form id="payment-form" method="post" action="{{ url_for('order_payment', oid=o.id) }}">
<h3>审批员登记付款</h3>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="row_version" value="{{ o.row_version }}">
<div class="field"><label for="payment-status">付款状态</label><select id="payment-status" name="payment_status">{% for key, label in PAYMENT_STATUS.items() %}<option value="{{ key }}" {{ 'selected' if o.payment_status == key else '' }}>{{ label }}</option>{% endfor %}</select></div>
<div class="field"><label for="payment-date">实际付款日期(部分付款或已付款时必填)</label><input id="payment-date" type="date" name="payment_date" value="{{ o.payment_date }}"></div>
<div class="field"><label for="payment-note">付款备注(选填)</label><textarea id="payment-note" name="payment_note" maxlength="500" placeholder="例如:已付首款,剩余尾款待交货后支付">{{ o.payment_note }}</textarea></div>
<div class="field"><label for="payment-reason">更正原因(回退状态时必填)</label><input id="payment-reason" name="reason" maxlength="500"></div>
<button class="btn primary">保存付款状态</button>
</form>
{% endif %}
</div>
</div>
+3 -1
View File
@@ -81,6 +81,8 @@
{% if o.note %}<p class="muted">补充说明:{{ o.note }}</p>{% endif %}
</div>
{% include 'order_tracking.html' %}
<div class="card">
<h2>审批流程</h2>
<table class="grid approval-flow">
@@ -172,7 +174,7 @@
<h3>提交版本</h3>
{% for version in o.versions %}<p><a href="{{ url_for('order_version',oid=o.id,revision=version.revision) }}">第 {{ version.revision }} 次提交 · {{ version.created_at.strftime('%Y-%m-%d %H:%M') }}</a>{{ ' · 已归档正式文件' if version.pdf_data else '' }}</p>
{% else %}<p class="muted">尚无提交快照;升级前的审批记录可在下方查看。</p>{% endfor %}
<h3>审批记录</h3>
<h3>审批与跟进记录</h3>
<ul class="timeline">
{% for lg in o.logs %}
<li>{{ lg.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ lg.user_name }} · {{ lg.action }}(第 {{ lg.revision }} 次提交)
+13 -2
View File
@@ -19,6 +19,10 @@
<input type="date" name="date_from" value="{{ args.get('date_from','') }}" style="width:150px;">
<span>~</span>
<input type="date" name="date_to" value="{{ args.get('date_to','') }}" style="width:150px;">
<select name="tracking" aria-label="付款与交货筛选" style="width:170px;">
<option value="">全部跟进情况</option>
{% for key, label in TRACKING_FILTERS.items() %}<option value="{{ key }}" {{ 'selected' if args.get('tracking') == key else '' }}>{{ label }}</option>{% endfor %}
</select>
<button class="btn primary">查询</button>
<span class="grow"></span>
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
@@ -27,7 +31,7 @@
<table class="grid">
<tr>
<th>单号</th><th>类型</th><th>供应商</th><th>采购日期</th><th class="num">金额</th>
<th>状态</th><th>制单</th><th>审批</th><th class="nowrap">操作</th>
<th>审批状态</th><th>付款与交货</th><th>制单</th><th>审批</th><th class="nowrap">操作</th>
</tr>
{% for o in rows %}
<tr>
@@ -37,6 +41,13 @@
<td class="nowrap">{{ o.order_date }}</td>
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
<td>
{% if o.needs_prepayment %}<span class="badge track-partial">需提前付款</span>{% endif %}
<span class="badge track-{{ o.invoice_status }}">收票:{{ o.invoice_status_text }}</span>
<span class="badge track-{{ o.payment_status }}">付款:{{ o.payment_status_text }}</span>
<span class="badge track-{{ o.delivery_status }}">交货:{{ o.delivery_status_text }}</span>
{% if o.fulfillment_stale or o.payment_stale %}<div class="tracking-warning">变更后待核对</div>{% endif %}
</td>
<td>{{ o.created_by_name }}</td>
<td>{{ o.approved_by or '-' }}</td>
<td class="nowrap">
@@ -45,7 +56,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="9" class="center muted">没有符合条件的订单</td></tr>
<tr><td colspan="10" class="center muted">没有符合条件的订单</td></tr>
{% endfor %}
</table>
</div>
+2 -1
View File
@@ -1,4 +1,4 @@
# 博特维采购订单管理系统 v3
# 博特维采购订单管理系统 v4
完整的运行、账号初始化、制单审批、撤回、作废、变更重审、备份和旧数据升级步骤,请阅读仓库根目录的 [运行与升级说明](../运行与升级说明.md)。
@@ -9,3 +9,4 @@
- 管理员在“通知配置”填写服务主机内网地址和企业微信群 Webhook,可发送测试通知并查看失败重试记录。
- 手机连公司 Wi-Fi 后打开订单通知链接,登录后回到订单;盖章并确认才会提交审批。
- 源码版本更新后,原有 EXE 需要重新打包。
- 管理员可兼任审批员;已审批订单由制单员维护预付款、收票和交货情况,审批员或管理员登记付款,工作台按各类状态汇总。
+2
View File
@@ -0,0 +1,2 @@
-r po_app/requirements.txt
PyInstaller==6.22.0
+48
View File
@@ -7,6 +7,54 @@ import pytest
SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql'
def test_v3_tracking_upgrade_preserves_existing_state_and_archives(tmp_path):
import hashlib
from models import Order, OrderVersion, User, init_db
path = tmp_path / 'po.db'
engine, factory = init_db(str(path))
old_pdf = b'%PDF-1.4 immutable v3 archive'
digest = hashlib.sha256(old_pdf).hexdigest()
with factory() as db:
db.add(User(id=1, username='owner', name='Owner', role='admin', password_hash='hash',
session_version=7, must_change_password=False))
db.add(Order(id=1, order_no='V3-APPROVED', status='approved', revision=1, row_version=1))
db.add(Order(id=2, order_no='V3-PENDING', status='pending_approval', revision=1))
db.flush()
db.add(OrderVersion(order_id=1, revision=1, content_json='{}', pdf_data=old_pdf,
pdf_sha256=digest, template_version='po-v3'))
db.commit()
engine.dispose()
with sqlite3.connect(path) as con:
for field in ('needs_prepayment', 'invoice_status', 'delivery_status', 'payment_status',
'payment_date', 'fulfillment_note', 'payment_note', 'fulfillment_revision', 'payment_revision'):
con.execute('ALTER TABLE orders DROP COLUMN ' + field)
con.execute('PRAGMA user_version=3')
engine, factory = init_db(str(path))
with factory() as db:
row = db.get(Order, 1)
assert row.status == 'approved' and row.revision == row.row_version == 1
assert row.invoice_status == row.delivery_status == row.payment_status == 'unknown'
assert row.fulfillment_revision == row.payment_revision == -1
assert not row.fulfillment_stale and not row.payment_stale
pending_order = db.get(Order, 2)
assert pending_order.status == 'pending_approval' and pending_order.row_version == 1
assert db.get(User, 1).session_version == 7
archive = db.query(OrderVersion).one()
assert (archive.pdf_data, archive.pdf_sha256, archive.template_version) == (old_pdf, digest, 'po-v3')
engine.dispose()
backups = list(tmp_path.glob('*.bak'))
assert len(backups) == 1
with sqlite3.connect(backups[0]) as con:
assert con.execute('PRAGMA user_version').fetchone()[0] == 3
assert 'payment_status' not in {row[1] for row in con.execute('PRAGMA table_info(orders)')}
with sqlite3.connect(path) as con:
assert con.execute('PRAGMA user_version').fetchone()[0] == 4
assert con.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
engine, _ = init_db(str(path))
engine.dispose()
assert len(list(tmp_path.glob('*.bak'))) == 1
def legacy(path, duplicate=False):
with sqlite3.connect(path) as con:
con.executescript(SCHEMA.read_text(encoding='utf-8'))
+162
View File
@@ -0,0 +1,162 @@
import io
import re
import secrets
from datetime import date, timedelta
import openpyxl
import pytest
from conftest import post
from test_workflow import action, approve, new, order, pending, save
def approved(app, clients):
oid = pending(app, clients)
approve(app, clients, oid)
return oid
def follow(app, clients, oid, actor='maker', **values):
fields = {'needs_prepayment': '1', 'invoice_status': 'received', 'delivery_status': 'partial',
'fulfillment_note': '已收第一批货'}
fields.update(values)
return action(app, clients[actor], oid, 'follow-up', **fields)
def pay(app, clients, oid, actor='approver', **values):
fields = {'payment_status': 'paid', 'payment_date': date.today().isoformat(), 'payment_note': '银行转账'}
fields.update(values)
return action(app, clients[actor], oid, 'payment', **fields)
def contains(client, key, number):
response = client.get('/orders?tracking=' + key)
assert response.status_code == 200
return number in response.get_data(as_text=True)
def test_follow_payment_dashboard_and_archived_pdf(app, clients):
oid = approved(app, clients)
pdf = clients['maker'].get('/orders/%s/pdf' % oid).data
assert follow(app, clients, oid).status_code == 302
with app.Session() as db:
number = db.get(app.Order, oid).order_no
for key in ('prepayment', 'invoice_due', 'partial_delivery', 'unpaid'):
assert contains(clients['maker'], key, number)
assert not contains(clients['other'], 'invoice_due', number)
assert pay(app, clients, oid, payment_status='partial').status_code == 302
assert contains(clients['admin'], 'partial_payment', number)
assert contains(clients['admin'], 'invoice_due', number)
assert pay(app, clients, oid, actor='admin').status_code == 302
assert not contains(clients['maker'], 'invoice_due', number)
assert not contains(clients['maker'], 'prepayment', number)
assert contains(clients['maker'], 'paid_undelivered', number)
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
assert contains(clients['approver'], 'completed', number)
assert not contains(clients['approver'], 'paid_undelivered', number)
assert clients['maker'].get('/orders/%s/pdf' % oid).data == pdf
dashboard = clients['admin'].get('/').get_data(as_text=True)
assert re.search(r'data-tracking="completed"[^>]*><div class="v">1</div>', dashboard)
other_dashboard = clients['other'].get('/').get_data(as_text=True)
assert number not in other_dashboard
with app.Session() as db:
logs = db.get(app.Order, oid).logs
assert any(lg.user_name == 'maker' and '部分交货' in (lg.comment or '') for lg in logs)
assert any(lg.user_name == 'admin' and '部分付款 → 已付款' in (lg.comment or '') for lg in logs)
workbook = openpyxl.load_workbook(io.BytesIO(clients['admin'].get('/export/ledger').data))
headers, values = list(workbook.active.values)
row = dict(zip(headers, values))
assert row['付款状态'] == '已付款' and row['交货状态'] == '已交货' and row['收票状态'] == '已收票'
workbook.close()
@pytest.mark.parametrize('actor,endpoint,status', [
('maker', 'payment', 403), ('other', 'payment', 404),
('other', 'follow-up', 404), ('approver', 'follow-up', 403),
])
def test_tracking_role_and_order_scope(app, clients, actor, endpoint, status):
oid = approved(app, clients)
function = pay if endpoint == 'payment' else follow
assert function(app, clients, oid, actor=actor).status_code == status
with app.Session() as db:
row = db.get(app.Order, oid)
assert row.payment_status == 'unpaid' and row.invoice_status == 'pending'
def test_tracking_rejects_forged_fields_csrf_and_stale_page(app, clients):
oid = approved(app, clients)
version = order(app, oid)['version']
assert follow(app, clients, oid, payment_status='paid').status_code == 403
assert pay(app, clients, oid, delivery_status='delivered').status_code == 403
assert clients['approver'].post('/orders/%s/payment' % oid, data={'payment_status': 'paid'}).status_code == 400
assert follow(app, clients, oid).status_code == 302
assert post(clients['approver'], '/orders/%s/payment' % oid,
{'row_version': str(version), 'payment_status': 'paid', 'payment_date': date.today().isoformat()}).status_code == 409
with app.Session() as db:
assert db.get(app.Order, oid).payment_status == 'unpaid'
def test_tracking_requires_approval_and_valid_input(app, clients):
oid = new(app, clients['maker'])
assert follow(app, clients, oid).status_code == 403
assert pay(app, clients, oid).status_code == 403
assert save(app, clients['maker'], oid, intent='submit').status_code == 302
assert pay(app, clients, oid).status_code == 403
approve(app, clients, oid)
assert pay(app, clients, oid, payment_date='').status_code == 400
assert pay(app, clients, oid, payment_date=(date.today()+timedelta(days=1)).isoformat()).status_code == 400
assert pay(app, clients, oid, payment_status='invalid').status_code == 400
assert follow(app, clients, oid, delivery_status='invalid').status_code == 400
assert follow(app, clients, oid, fulfillment_note='x'*501).status_code == 400
assert action(app, clients['admin'], oid, 'void', comment='取消采购').status_code == 302
assert pay(app, clients, oid).status_code == 403
assert follow(app, clients, oid).status_code == 403
def test_corrections_require_reason_and_keep_audit(app, clients):
oid = approved(app, clients)
assert pay(app, clients, oid).status_code == 302
assert pay(app, clients, oid, payment_status='unpaid').status_code == 400
assert pay(app, clients, oid, payment_status='unpaid', reason='误登记,实际尚未转账').status_code == 302
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
assert follow(app, clients, oid, invoice_status='pending').status_code == 400
assert follow(app, clients, oid, invoice_status='pending', delivery_status='pending', reason='收票和收货录入有误').status_code == 302
with app.Session() as db:
row = db.get(app.Order, oid)
assert row.payment_status == 'unpaid' and row.payment_date == ''
assert row.delivery_status == row.invoice_status == 'pending'
assert any('误登记,实际尚未转账' in (log.comment or '') for log in row.logs)
def test_revision_requires_each_role_to_recheck_and_copy_starts_fresh(app, clients):
oid = approved(app, clients)
follow(app, clients, oid, delivery_status='delivered')
pay(app, clients, oid)
with app.Session() as db:
number = db.get(app.Order, oid).order_no
assert action(app, clients['maker'], oid, 'revise', comment='调整数量').status_code == 302
assert save(app, clients['maker'], oid, qty='5', intent='submit').status_code == 302
approve(app, clients, oid)
assert contains(clients['admin'], 'recheck', number)
assert not contains(clients['admin'], 'completed', number)
assert '订单内容已变更' in clients['maker'].get('/orders/%s/view' % oid).get_data(as_text=True)
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
assert contains(clients['admin'], 'recheck', number)
assert pay(app, clients, oid).status_code == 302
assert not contains(clients['admin'], 'recheck', number)
assert contains(clients['admin'], 'completed', number)
copied = action(app, clients['maker'], oid, 'copy', request_key=secrets.token_hex(16))
assert copied.status_code == 302
copy_id = int(re.search(r'/orders/(\d+)', copied.location)[1])
with app.Session() as db:
clone = db.get(app.Order, copy_id)
assert not clone.needs_prepayment
assert clone.invoice_status == clone.delivery_status == 'pending'
assert clone.payment_status == 'unpaid' and clone.payment_date == ''
def test_admin_can_follow_and_pay_and_invalid_filter_is_rejected(app, clients):
oid = approved(app, clients)
assert follow(app, clients, oid, actor='admin').status_code == 302
assert pay(app, clients, oid, actor='admin').status_code == 302
assert clients['admin'].get('/orders?tracking=invalid').status_code == 400
+92 -6
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():
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)
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() == ''
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,6 +214,26 @@ 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'
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=[]
@@ -168,10 +246,18 @@ def run():
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)
+545
View File
@@ -0,0 +1,545 @@
"""Build the v4 PDF manual from verified UI screenshots; no business data is read."""
import argparse
from pathlib import Path
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Flowable, KeepTogether, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
ROOT = Path(__file__).resolve().parents[1]
W = 174 * mm
NAVY = colors.HexColor('#163957')
BLUE = colors.HexColor('#246AB0')
INK = colors.HexColor('#253546')
GRAY = colors.HexColor('#66788A')
PALE = colors.HexColor('#EDF4FA')
LINE = colors.HexColor('#D6E1EA')
TEAL = colors.HexColor('#087A74')
TITLES = [
'封面', '目录与操作路线', '首次安装与管理员初始化', '局域网与手机访问',
'账号分工与基础资料', '制单、导入与提交', '审批、采购章与正式 PDF',
'收票、付款与交货跟进', '工作台与订单状态解读', '企业微信:创建群消息推送',
'企业微信:连接采购订单系统', '企业微信:手机验收与排查',
'旧版本升级到 v4', '日常备份与数据恢复', '更换服务电脑与回退', '常见问题与上线核对',
]
def register_fonts():
pdfmetrics.registerFont(TTFont('Manual', 'C:/Windows/Fonts/msyh.ttc', subfontIndex=0))
pdfmetrics.registerFont(TTFont('ManualBold', 'C:/Windows/Fonts/msyhbd.ttc', subfontIndex=0))
pdfmetrics.registerFontFamily('Manual', normal='Manual', bold='ManualBold', italic='Manual', boldItalic='ManualBold')
class Screenshot(Flowable):
"""Place an original screenshot through a PDF clipping region."""
def __init__(self, path, width=W, region=None):
super().__init__()
self.reader = ImageReader(str(path))
self.iw, self.ih = self.reader.getSize()
self.region = region or (0, 0, self.iw, self.ih)
self.width = width
self.height = (self.region[3] - self.region[1]) * width / (self.region[2] - self.region[0])
def draw(self):
c = self.canv
x0, y0, x1, _ = self.region
scale = self.width / (x1 - x0)
c.saveState()
path = c.beginPath()
path.rect(0, 0, self.width, self.height)
c.clipPath(path, stroke=0)
c.drawImage(self.reader, -x0 * scale, self.height + (y0-self.ih)*scale,
self.iw*scale, self.ih*scale, mask='auto')
c.restoreState()
c.setStrokeColor(LINE)
c.setLineWidth(0.5)
c.rect(0, 0, self.width, self.height, stroke=1, fill=0)
class Network(Flowable):
def __init__(self):
super().__init__()
self.width, self.height = W, 47 * mm
def draw(self):
c = self.canv
c.setStrokeColor(BLUE)
for y in (35*mm, 11*mm):
c.line(77*mm, 24*mm, 105*mm, y)
for x, y, width, height, title, sub in [
(0, 6*mm, 77*mm, 36*mm, '服务电脑', '运行 EXE · 保存统一数据库'),
(105*mm, 25*mm, 69*mm, 20*mm, '员工电脑', '浏览器访问同一个地址'),
(105*mm, 1*mm, 69*mm, 20*mm, '审批手机', '连接公司 Wi-Fi 后访问'),
]:
c.setFillColor(PALE)
c.roundRect(x, y, width, height, 6, stroke=0, fill=1)
c.setFillColor(NAVY)
c.setFont('ManualBold', 12)
c.drawCentredString(x+width/2, y+height/2+4, title)
c.setFillColor(GRAY)
c.setFont('Manual', 8.6)
c.drawCentredString(x+width/2, y+height/2-12, sub)
class Cover(Flowable):
def __init__(self):
super().__init__()
self.width, self.height = W, 697
def draw(self):
c = self.canv
c.bookmarkPage('p01')
c.addOutlineEntry('封面', 'p01', 0)
c.setFillColor(NAVY)
c.roundRect(0, 257, W, 405, 12, stroke=0, fill=1)
c.setFillColor(colors.HexColor('#66BCDF'))
c.rect(26, 620, 42, 4, stroke=0, fill=1)
c.setFont('Manual', 12)
c.drawString(26, 586, '苏州博特维智能科技有限公司')
c.setFillColor(colors.white)
c.setFont('ManualBold', 29)
c.drawString(26, 512, '采购订单系统')
c.setFont('ManualBold', 25)
c.drawString(26, 466, '使用与升级手册')
c.setFont('Manual', 12)
c.setFillColor(colors.HexColor('#DAE9F4'))
c.drawString(28, 408, 'v4 / Windows 64 位单文件版')
c.setFont('Manual', 10.5)
c.drawString(28, 352, '制单审批 · 采购专用章 · 收票付款 · 交货跟进')
c.drawString(28, 328, '企业微信通知 · 公司局域网协作 · 数据升级与恢复')
c.setFillColor(NAVY)
c.setFont('ManualBold', 14)
c.drawString(0, 212, '一台服务主机,一套业务数据')
c.setFont('Manual', 11)
c.setFillColor(INK)
c.drawString(0, 181, '管理员维护服务电脑,制单员和审批员通过浏览器协作。')
c.drawString(0, 157, '更新程序时保留原数据目录,账号、订单和归档文件继续使用。')
c.setStrokeColor(LINE)
c.line(0, 118, W, 118)
c.setFont('Manual', 10)
c.setFillColor(GRAY)
c.drawString(0, 89, '适用人员:系统管理员、制单员、审批员')
c.drawString(0, 65, '手册版本:v4.0 编制日期:2026-09-16')
c.drawString(0, 27, '界面图片来自隔离测试数据,仅用于说明操作方法。')
class ManualDoc(SimpleDocTemplate):
def afterFlowable(self, flowable):
bookmark = getattr(flowable, 'manual_bookmark', None)
if bookmark:
self.canv.bookmarkPage(bookmark)
self.canv.addOutlineEntry(flowable.getPlainText(), bookmark, 0)
def build(screenshots, output):
register_fonts()
styles = {
'body': ParagraphStyle('body', fontName='Manual', fontSize=10.1, leading=17, textColor=INK,
wordWrap='CJK', spaceAfter=8),
'small': ParagraphStyle('small', fontName='Manual', fontSize=8.5, leading=13.5, textColor=GRAY,
wordWrap='CJK', spaceAfter=6),
'h1': ParagraphStyle('h1', fontName='ManualBold', fontSize=21, leading=29, textColor=NAVY,
wordWrap='CJK', spaceAfter=12),
'h2': ParagraphStyle('h2', fontName='ManualBold', fontSize=12, leading=19, textColor=NAVY,
wordWrap='CJK', spaceBefore=10, spaceAfter=6),
'cell': ParagraphStyle('cell', fontName='Manual', fontSize=9.1, leading=14.5, textColor=INK,
wordWrap='CJK'),
'headcell': ParagraphStyle('headcell', fontName='ManualBold', fontSize=9.1, leading=14.5,
textColor=colors.white, wordWrap='CJK'),
'kicker': ParagraphStyle('kicker', fontName='ManualBold', fontSize=9, leading=15, textColor=TEAL,
spaceAfter=5),
}
story = [Cover()]
def p(text, style='body'):
return Paragraph(text, styles[style])
def add(text, style='body'):
story.append(p(text, style))
def section(page, title, lead):
story.append(PageBreak())
add('阅读导航' if page == 2 else '%02d / 操作指南' % (page-2), 'kicker')
title_para = p(title, 'h1')
title_para.manual_bookmark = 'p%02d' % page
story.append(title_para)
add(lead)
def table(headers, rows, widths=None):
data = [[p(escape(x), 'headcell') for x in headers]]
data += [[p(str(x), 'cell') for x in row] for row in rows]
tab = Table(data, colWidths=widths or [W/len(headers)]*len(headers), hAlign='LEFT', repeatRows=1)
tab.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), NAVY), ('VALIGN', (0,0), (-1,-1), 'TOP'),
('ROWBACKGROUNDS', (0,1), (-1,-1), [colors.white, colors.HexColor('#F5F8FB')]),
('LEFTPADDING', (0,0), (-1,-1), 8), ('RIGHTPADDING', (0,0), (-1,-1), 8),
('TOPPADDING', (0,0), (-1,-1), 7), ('BOTTOMPADDING', (0,0), (-1,-1), 7),
('LINEBELOW', (0,0), (-1,0), 0.5, NAVY), ('LINEBELOW', (0,1), (-1,-1), 0.4, LINE),
]))
story.extend([tab, Spacer(1, 9)])
def note(title, body):
box = Table([[p(title, 'h2')], [p(body)]], colWidths=[W])
box.setStyle(TableStyle([('BACKGROUND', (0,0), (-1,-1), PALE),
('LINEBEFORE', (0,0), (0,-1), 3, BLUE), ('LEFTPADDING', (0,0), (-1,-1), 12),
('RIGHTPADDING', (0,0), (-1,-1), 12), ('TOPPADDING', (0,0), (-1,0), 8),
('BOTTOMPADDING', (0,-1), (-1,-1), 7)]))
story.append(KeepTogether([Spacer(1, 4), box, Spacer(1, 10)]))
def steps(items, compact=False):
for i, (title, body) in enumerate(items, 1):
add('<b>%s. %s</b>%s%s' % (i, title, ':' if compact else '<br/>', body))
def shot(name, caption, region=None, width=W):
story.append(Screenshot(screenshots/name, width, region))
story.append(Spacer(1, 5))
add(caption, 'small')
# 02: navigation
section(2, '目录与操作路线', '按岗位阅读,也可点击下面的章节标题跳转。所有步骤以 v4 界面上的按钮名称为准。')
table(['章节', '主要内容', '页码'], [
['<link href="#p03">首次安装与管理员初始化</link>', '解压、启动、setup.token、管理员账号', '03'],
['<link href="#p04">局域网与手机访问</link>', '服务主机、IP、端口、公司 Wi-Fi', '04'],
['<link href="#p05">账号分工与基础资料</link>', '管理员兼任审批员、供应商与物料', '05'],
['<link href="#p06">制单、导入与提交</link>', '采购单、委外单、Excel 明细', '06'],
['<link href="#p07">审批、采购章与正式 PDF</link>', '通过、驳回、撤回、变更和作废', '07'],
['<link href="#p08">收票、付款与交货跟进</link>', '部分完成、付款日期、更正原因', '08'],
['<link href="#p09">工作台与订单状态解读</link>', '分类数量、订单筛选、台账', '09'],
['<link href="#p10">企业微信审批通知:建群与机器人</link>', '<link href="#p11">系统配置</link> · <link href="#p12">手机验收与排查</link>', '10-12'],
['<link href="#p13">旧版本升级到 v4</link>', '停机备份、替换程序、升级核对', '13'],
['<link href="#p14">日常备份与数据恢复</link>', '保留哪些文件、备份和恢复顺序', '14'],
['<link href="#p15">更换服务电脑与回退</link>', '迁移整套系统、恢复旧版', '15'],
['<link href="#p16">常见问题与上线核对</link>', '打不开、空数据库、状态异常', '16'],
], [69*mm, 89*mm, 16*mm])
add('按岗位快速开始', 'h2')
add('<b>管理员:</b>先读第 3-5 页;企业微信设置读第 10-12 页;上线核对及升级维护读第 13-16 页。')
add('<b>制单员:</b>第 6 页制单 → 第 7 页提交审批 → 第 8-9 页维护收票与交货。')
add('<b>审批员:</b>第 7 页审批与盖章 → 第 8 页登记付款 → 第 9 页查看待办。')
note('一张订单的基本路线', '创建草稿 → 保存并提交审批 → 审批员确认并归档 PDF → 分别跟进收票、付款和交货。付款、收票、交货是独立状态,均在审批通过后登记。')
# 03: install
section(3, '首次安装与管理员初始化', '适用:没有旧数据的新部署。已有账号和订单时,请直接按第 13 页升级,或按第 15 页迁移。')
steps([
('确定服务电脑', '选择一台固定、可持续开机的 Windows 64 位电脑。其他电脑和手机使用浏览器访问它。'),
('解压到固定文件夹', '把测试包完整解压,例如放到 <b>D:\\采购订单系统</b>。EXE 可直接运行,无需安装 Python;后续数据会默认写入 EXE 所在目录。'),
('双击启动 EXE', '双击“博特维采购订单系统_单文件版.exe”,等待程序启动并打开浏览器。若未自动打开,按程序窗口显示的本机地址访问,通常是 <b>http://127.0.0.1:8080</b>。'),
('读取初始化口令', '在服务电脑上用记事本打开 <b>instance\\setup.token</b>,复制其中的口令。该文件位于 EXE 同目录的 instance 文件夹内。'),
('创建管理员', '在“首次设置管理员”页填写初始化口令、管理员登录名、姓名和密码,点击“创建管理员”。密码需为 10 至 128 个字符,避免常见弱密码。'),
('建立使用账号', '进入“用户”,创建制单员、审批员,按第 5 页分配权限。新建或重置密码的账号首次登录须修改密码。'),
])
table(['启动后可见的内容', '用途'], [
['博特维采购订单系统_单文件版.exe', '启动系统的程序文件'],
['instance\\setup.token', '首次创建管理员使用的初始化口令'],
['instance\\po.db', '统一保存账号、订单、审批、跟进及正式 PDF'],
['instance\\session.key', '部署会话密钥,随数据目录妥善保管'],
['uploads / exports', '上传与导出相关文件;备份时一并保留'],
], [84*mm, 90*mm])
note('setup.token 与“同一套系统”', '初始化口令由程序自动生成。是否使用同一套系统取决于数据库:只把 EXE 放到另一台电脑的新文件夹运行,会建立独立数据库。正式使用时统一访问服务主机;更换主机须迁移原数据目录。')
# 04: local network
section(4, '局域网与手机访问', '由服务电脑统一运行程序、保存数据;员工电脑和手机通过浏览器使用同一套系统。')
story.append(Network())
story.append(Spacer(1, 12))
steps([
('查看服务电脑的内网地址', '在服务电脑按 Win + R,输入 cmd 并回车,执行 <b>ipconfig</b>,查看实际连接公司网络的网卡的 IPv4 地址。示例:192.168.1.100。'),
('确认实际端口', '以 EXE 程序窗口打印的访问端口为准。默认从 8080 开始,端口占用时可能改用后续端口。'),
('从其他设备打开', '同一公司局域网的电脑浏览器访问 <b>http://192.168.1.100:8080</b>;手机连接可与主机互通的公司 Wi-Fi 后访问相同地址。示例 IP 和端口必须换成实际值。'),
('登录并保存入口', '每人使用自己的账号登录,将地址加入浏览器收藏夹。审批链接也使用这个内网地址。'),
])
table(['地址示例', '使用位置'], [
['http://127.0.0.1:8080', '仅供运行 EXE 的服务电脑本机访问'],
['http://192.168.1.100:8080', '其他电脑和手机访问服务主机;使用实际 IP 与端口'],
], [90*mm, 84*mm])
add('其他设备打不开时,按顺序检查', 'h2')
add('先确认服务电脑本机能打开,再核对 IP 与端口;由网管允许公司专用网络访问该服务端口,并确认 Wi-Fi 没有设备隔离、访客网络隔离或 VLAN 限制。')
note('保持服务地址稳定', '给服务电脑使用固定内网 IP 或 DHCP 地址保留,并保持开机、避免休眠。程序窗口关闭后服务停止。手机在公司网络外收到通知时,需要回到公司网络后打开订单。')
# 05: roles and master data
section(5, '账号分工与基础资料', '管理员可以兼任审批员,无需另外创建审批账号。制单与审批仍须由不同账号完成。')
table(['功能', '制单员', '审批员', '管理员'], [
['查看订单', '本人订单', '全部订单', '全部订单'],
['制单 / 编辑草稿', '本人订单', '不提供', '可以'],
['审批通过 / 驳回', '不提供', '他人制单', '他人制单'],
['预付款、收票、交货跟进', '本人已审批订单', '查看', '全部已审批订单'],
['付款状态登记', '查看', '可以', '可以'],
['供应商 / 物料维护', '可以', '不提供', '可以'],
['账号与通知配置', '不提供', '不提供', '可以'],
], [60*mm, 38*mm, 38*mm, 38*mm])
add('管理员添加或维护账号', 'h2')
steps([
('新增用户', '进入“用户”,填写登录名、姓名、角色、启用状态和初始密码,点击“保存用户”。'),
('首次登录改密', '将初始登录信息单独交给使用人;使用人第一次登录后按页面要求修改密码。管理员重置密码后也需要重新改密。'),
('调整权限或停用', '在用户列表点击“编辑 / 重置密码”。编辑账号会使其原登录失效;人员离岗时可停用账号。系统必须保留至少一个启用的管理员。'),
])
add('先维护供应商与物料库', 'h2')
add('在“供应商”录入名称、联系人、付款方式、税号及银行信息;在“物料库”录入物料代码、规格、单位和默认含税单价。可下载对应模板,按模板导入 XLS 或 XLSX。停用的资料会保留历史记录,但不再作为有效下单选项。')
note('下单时再核对一次', '供应商库和物料库用于带入默认值。每张订单仍应核对价格、交期、付款方式和收款信息;修改基础资料不会自动重写已有订单。')
# 06: create and submit
section(6, '制单、导入与提交', '采购单和委外生产订单使用相同操作流程。建议先完成单据信息,再录入或导入物料明细。')
steps([
('创建订单', '进入“新建订单”,选择单据类型及需要带入的供应商,点击“创建并填写明细”。系统自动分配单号。'),
('核对单据信息', '填写采购日期、供应商、付款方式、需方名称和交货地点;核对联系人、税号、税点和银行信息。'),
('添加物料', '点击“+ 添加一行”,填写物料代码或规格、单位、数量、含税单价、交期和备注。可从物料库带入后修改。'),
('导入 Excel 明细', '先下载页面提供的导入模板,按表头填写,选择 XLS / XLSX 文件及追加或替换方式,再点击“导入”。默认追加明细;选择替换前先确认已有内容,导入后逐行核对。'),
('保存或提交', '内容未完成时点击“保存草稿”;核对完成后点击“保存并提交审批”,当前修改会一起保存并进入“待审批”。'),
])
table(['提交前检查', '填写要求'], [
['抬头', '供应商、需方名称、付款方式、采购日期、交货地点'],
['明细', '至少一行有效物料;物料代码或规格、单位、正数量、正单价、交期'],
['数字精度', '数量与单价最多四位小数;行金额四舍五入到分,再求合计'],
['日期', '使用日期控件,或按 YYYY-MM-DD 填写,例如 2026-10-01'],
], [39*mm, 135*mm])
note('提交后如何修改', '待审批期间,制单人先在详情页填写撤回原因并“撤回修改”。审批通过后修改订单内容,须“发起变更重审”,原批准停止使用;跟进状态的修改使用第 8 页的专门表单。')
add('导入文件最大 8 MB。零价、负数、非法数字或超过允许精度的数值无法正式提交。金额与人民币大写以系统计算结果为准。', 'small')
# 07: approve and archive
section(7, '审批、采购章与正式 PDF', '审批员或管理员打开他人提交的订单,核对供应商、明细、金额、交期和付款方式后完成一次审批。')
approval_steps = [
p('通过审批', 'h2'),
p('<b>1.</b> 在工作台“待我处理”或订单列表打开待审批订单。'),
p('<b>2.</b> 点击“加盖采购专用章”,核对红色章面预览;可填写审批意见。'),
p('<b>3.</b> 点击“确认审批通过”,并确认提示。预览章面本身还没有提交审批。'),
p('<b>4.</b> 成功后,点击“导出 / 预览 PDF”或“下载 PDF”,取得已归档的正式文件。'),
p('需要退回时', 'h2'),
p('填写驳回原因,点击“驳回给制单人”。制单人修改后重新提交。'),
p('管理员兼任审批员时,也不能审批由同一账号创建的订单。', 'small'),
]
layout = Table([[Screenshot(screenshots/'purchase-mobile-approve.png', 64*mm, (0,200,390,815)), approval_steps]],
colWidths=[73*mm, 101*mm], hAlign='LEFT')
layout.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP'),('LEFTPADDING',(0,0),(0,0),0),
('RIGHTPADDING',(0,0),(0,0),9),('LEFTPADDING',(1,0),(1,0),5)]))
story.extend([layout, Spacer(1, 5)])
add('手机审批示例。实际单号、金额与角色权限以业务订单为准。', 'small')
table(['后续动作', '处理方式'], [
['批准后修改内容', '填写原因并“发起变更重审”,修改后重新审批;原正式文件停止使用'],
['作废订单', '符合权限的人员填写原因后作废;作废单当前导出带水印'],
['重复下载正式 PDF', '下载同一归档文件;更新收票、付款或交货标记不会改变该文件'],
], [42*mm, 132*mm])
add('采购专用章内不显示审批员和日期;审批人及时间保留在记录与 PDF 签署区。章面号码为 3205070994140,订单税号仍使用订单本身的税号。该章用于内部审批确认。', 'small')
# 08: tracking
section(8, '收票、付款与交货跟进', '入口:已审批订单详情 →“付款与交货跟进”。每次修改均留下操作人、时间和变更内容。')
tracking_steps = [
p('制单员:采购跟进', 'h2'),
p('<b>1.</b> 打开本人已审批订单。在“付款安排”选择“需提前付款”或“正常付款”。'),
p('<b>2.</b> 实际收到供应商发票后,将收票情况设为“已收票”。'),
p('<b>3.</b> 按实际情况选择“未交货 / 部分交货 / 已交货”,填写跟进备注,点击“保存采购跟进”。'),
p('审批员 / 管理员:付款', 'h2'),
p('<b>1.</b> 在“审批员登记付款”选择“未付款 / 部分付款 / 已付款”。'),
p('<b>2.</b> 部分付款或已付款时,填写实际付款日期;日期不能晚于今天。'),
p('<b>3.</b> 填写付款备注,点击“保存付款状态”。管理员也可维护采购跟进。'),
]
layout = Table([[Screenshot(screenshots/'purchase-mobile-tracking.png', 65*mm, (0,0,390,650)), tracking_steps]],
colWidths=[73*mm,101*mm], hAlign='LEFT')
layout.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP'),('LEFTPADDING',(0,0),(0,0),0),
('RIGHTPADDING',(0,0),(0,0),9),('LEFTPADDING',(1,0),(1,0),5)]))
story.extend([layout, Spacer(1, 8)])
add('部分完成怎样记录', 'h2')
add('付款与交货分别标注。例如“部分付款 + 未交货”表示已付一部分款但尚未交货;“已付款 + 部分交货”表示款项已付清、货尚未交齐。当前按整张订单记录状态,具体金额、比例或交货批次写在备注中。')
note('更正与变更后的核对', '回退已收票、已交货或付款状态时,必须填写更正原因。订单内容变更并重新提交后,原跟进记录会提示待核对;制单员核对采购跟进,审批员或管理员核对付款,再分别保存确认。')
add('旧订单显示“未登记”时,请根据原始业务凭据补录。此状态不表示实际未付款、未收票或未交货。', 'small')
# 09: dashboard
section(9, '工作台与订单状态解读', '工作台集中展示审批和业务跟进情况。点击分类数量可进入对应订单列表,再点击订单查看或登记跟进。')
shot('tracking-dashboard.png', '工作台示例:一张订单部分付款、部分交货;另一张已付款但未交货。', (145,190,1296,661))
table(['工作台分类', '计入条件与使用方法'], [
['需预付款', '已标记提前付款,且付款状态为未付款或部分付款;预付款标记由制单员维护'],
['已收票待付款', '已收票,且付款状态为未付款或部分付款'],
['未付款 / 部分付款 / 已付款', '分别显示对应付款状态;“未付款”数量不包含部分付款'],
['未交货 / 部分交货 / 已交货', '分别显示对应交货状态;“未交货”数量不包含部分交货'],
['已付款未交齐', '已付款,交货为未交货或部分交货'],
['跟进待登记 / 变更后待核对', '用于补录历史情况,或重新确认订单内容变更前的跟进记录'],
], [53*mm,121*mm])
add('看数量时注意口径', 'h2')
add('跟进分类只统计当前可见且审批通过的订单。制单员看本人订单,审批员和管理员看全部订单。各类可能重叠,不能把所有卡片数量相加当作订单总数;变更前尚未核对的记录不会进入对应完成或待付款分类。')
add('“最近审批通过的订单”显示最近 12 张,更多订单用分类筛选或“查看全部”。订单列表可结合单号、供应商、审批状态、类型、日期与跟进情况查询;“导出 Excel 台账”导出该账号可见的全部订单,包含跟进状态、日期和备注。', 'small')
# 10: WeCom group setup
section(10, '企业微信:创建群消息推送', '在电脑端企业微信创建群消息推送,再把生成的 Webhook 地址交给采购系统管理员。以下按企业微信 5.0 的入口说明,其他版本可参考下方旧入口。')
table(['先准备好', '具体安排'], [
['一个企业内部群', '建议命名“采购订单审批”;加入审批员和负责维护的管理员。使用本企业成员组成的内部群,外部群不适用此方式'],
['一个负责配置的人员', '由群主或能使用该入口的成员创建机器人,并保管地址;采购系统内的通知设置由管理员操作'],
], [44*mm,130*mm])
steps([
('打开目标群', '登录电脑端企业微信,在现有内部群中操作;尚未建群时,先从企业通讯录选择相关同事发起群聊。'),
('进入群管理', '打开群聊右上角的“…”或群设置入口;也可右键该会话,选择“管理聊天信息”。'),
('选择消息推送', '进入“消息推送”,点击“添加”,选择“自定义消息推送”。部分界面会直接打开创建窗口。'),
('填写名称并保存', '名称可填“采购订单通知”,简介可填“采购订单待审批及处理结果提醒”,按界面要求设置头像,点击“保存”。'),
('复制完整地址', '创建完成后,在详情中复制 Webhook 地址,保留从 https:// 开头到 key 后全部内容。接着按第 11 页填写采购系统。'),
])
note('找不到“消息推送”时', '旧版本入口可能为“添加群机器人 / 群机器人 → 添加机器人 → 新创建一个机器人”。仍无入口时,先确认是企业内部群,再让群主或企业微信管理员检查创建权限;不要改用带“外部”标记的群。')
add('已有机器人的地址通常由创建者在群内机器人详情或成员列表中的机器人卡片查看。由原创建者复制交接;不应将地址发到普通群聊或放入公开文档。', 'small')
add('填入采购系统并保存后,Webhook 不回显;以后留空会保留原配置,更换时粘贴新地址并保存,删除时勾选“清除此群配置”。结果群未配置时,不发送审批结果通知。', 'small')
add('入口参考:<link href="https://cloud.tencent.com/document/product/1759/128391" color="#246AB0">腾讯官方《企业微信群机器人获取 Webhook》</link>(企业微信 5.0);旧入口参考:<link href="https://cloud.tencent.com/document/product/1551/84530" color="#246AB0">腾讯官方《企业微信群机器人接收消息》</link>。核对日期:2026-09-16。', 'small')
# 11: connect WeCom to this application
section(11, '企业微信:连接采购订单系统', '使用采购系统的管理员账号登录,在顶部菜单打开“通知配置”。以下地址均为示例,必须换成本公司的实际值。')
table(['采购系统里的字段', '怎样填写'], [
['系统内网访问地址', '例如 http://192.168.1.100:8080;填服务电脑实际 IP 和端口,只填根地址,不带 /orders 等路径'],
['审批通知群 Webhook', '粘贴第 10 页复制的完整地址,用于接收新提交的待审批通知'],
['结果通知群 Webhook(可选)', '想在同一群接收审批结果,就再粘贴一次同一地址;想分群,就在结果群另建机器人,粘贴该群地址'],
['启用企业微信通知', '勾选后点击“保存通知配置”;只填地址但未勾选,不会发送'],
], [54*mm,120*mm])
add('Webhook 的格式应类似下面这样;YOUR_KEY 只是示意,不能照抄:', 'small')
add('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY', 'small')
shot('notifications.png', '通知配置界面示例;图中使用测试地址,通知未启用。实际操作须填写本公司的真实配置。', (145,245,1295,605))
steps([
('先验证群消息', '保存后点击“测试审批通知群”,等待几秒,刷新“最近 100 条通知”,并在实际企业微信群中确认收到“采购订单测试通知”。'),
('再验证结果群', '如填写了结果群,再点“测试结果通知群”。两项使用同一群时,群里会各收到一条测试通知。'),
], compact=True)
note('两个地址各有用途', 'Webhook 用于服务电脑向企业微信发送消息;内网地址用于手机打开采购系统。服务电脑须能访问企业微信接口,手机须能通过公司 Wi-Fi 访问主机。本功能无需填写 CorpID、AgentID 或 Secret,也无需将采购系统开放到公网。')
# 12: mobile acceptance and troubleshooting
section(12, '企业微信:手机验收与排查', '测试通知成功后,再用一张测试订单走完下面的流程。审批操作在采购订单系统页面完成,使用各自的采购系统账号登录。')
steps([
('确认手机能访问', '连接公司 Wi-Fi,在手机浏览器打开第 11 页填写的内网地址。能看到登录页后,再测试企业微信中的链接。'),
('提交一张测试订单', '制单员提交后,审批群应收到含单号的通知。审批员点击“打开订单”,登录自己的账号,核对订单后按第 7 页盖章并确认通过。'),
('核对结果与权限', '已配置结果群时,应收到审批结果。管理员可审批他人订单;制单人不能审批自己创建的订单。测试通知中的“打开工作台”只用于验证入口。'),
], compact=True)
table(['遇到的情况', '按这个顺序处理'], [
['测试按钮是灰色', '先填写所测群的地址,勾选启用并保存,再重新打开配置页'],
['Webhook 保存失败', '重新复制完整地址,确认含 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key= 及其后的完整密钥;只复制 key 不够'],
['群里没有通知', '查看是否进入通知队列;等待发送 / 重试时保持 EXE 运行。失败时检查外网、机器人是否仍有效,修正后点“重试”'],
['群消息有了,订单打不开', '检查公司 Wi-Fi、主机 IP、实际端口和防火墙;不要使用 localhost 或 127.0.0.1。企业微信内置页面受限时,可复制订单链接到手机浏览器验证'],
['打开订单却无审批按钮', '核对登录账号角色、是否为制单人,以及订单是否仍待审批;群成员身份不会自动赋予系统审批权限'],
['测试能收到,审批结果没有', '检查结果群是否配置并保存;启用后重新发生的操作才会产生通知,历史操作不补发'],
], [47*mm,127*mm])
add('日常使用时记住这几项', 'h2')
add('结果群接收通过、驳回、撤回、变更和作废消息;付款与交货跟进修改暂不单独推送。关闭通知会暂停队列,重新启用后继续处理仍有效的通知;旧待办失效后会标为“已跳过”。', 'small')
add('发送失败最多尝试 5 次;网络超时重试可能造成重复消息,以订单当前状态为准。群里收到通知不等于已审批,仍须登录后确认;手机在公司网络外收到通知时,回到公司网络后再打开。', 'small')
add('创建者离岗或机器人被删除、重建时,应交接并核对 Webhook;地址变化后更新系统两项配置并重新测试。换服务电脑或 IP 时,同时更新系统内网地址。', 'small')
add('群类型限制参考:<link href="https://cloud.tencent.com/document/product/248/50413" color="#246AB0">腾讯官方《使用企业微信群接收告警通知》</link>。以上采购系统按钮、队列与权限说明以本项目 v4 为准。', 'small')
# 13: upgrade
section(13, '旧版本升级到 v4', '只升级运行 EXE 的服务电脑。员工电脑和手机继续使用统一浏览器入口,原账号和订单应保留。')
note('升级前先确认数据位置', '默认数据在 EXE 同目录的 instance 文件夹内。如果部署时设置过 PO_DATA_DIR,须备份并继续使用该设置指向的数据目录;不应把新包直接当成一套空系统使用。')
steps([
('安排停机窗口', '通知使用人暂停制单和审批,记录当前程序版本、服务地址、订单数量、最新单号及关键订单状态。'),
('关闭旧程序', '关闭服务主机上运行 EXE 的窗口,确认旧服务已停止,避免新旧程序同时维护同一数据库。'),
('完整备份', '把原程序文件夹复制到带日期的备份目录;包含旧 EXE、完整 instance、uploads 和 exports。自定义数据目录也要完整复制。'),
('替换程序文件', '将新包先解压到单独位置,再把新版 EXE 复制到原程序目录,替换旧 EXE。保留原数据目录和其他业务文件。'),
('启动并等待升级', '双击新版 EXE。发现旧数据库时,程序会先生成升级前备份,再迁移数据结构。若提示升级错误,保留错误信息和备份并停止操作,按第 15 页处理。'),
('核对后恢复使用', '用原管理员账号登录,查看页脚 v4.0;核对原用户、最新单号、订单数量、审批记录及至少一份历史正式 PDF,再让使用人恢复业务。'),
])
table(['从哪个版本升级', '主要变化'], [
['v3 → v4', '增加收票、付款和交货跟进;历史状态显示“未登记”,账号、审批与归档 PDF 保留'],
['v2 或更早版本', '同时完成单次审批及此前数据库升级;旧主管角色会改为审批员,部分旧账号需要重新登录或改密'],
], [40*mm,134*mm])
add('升级后重点补录', 'h2')
add('在工作台进入“跟进待登记”,由制单员补录收票、交货和预付款安排,由审批员或管理员补录付款。已归档 PDF 保持原文件,旧章面不会因升级自动替换。')
# 14: backup and restore
section(14, '日常备份与数据恢复', 'EXE 使用人员可采用停机后完整复制文件夹的方式备份,无需安装 Python。程序、数据库和相关文件应成套保管。')
table(['应保留的内容', '为什么需要'], [
['完整 instance 文件夹', '包括 po.db、session.key、已有备份等;保存账号、订单、审批、跟进、正式 PDF 与通知配置'],
['uploads、exports 文件夹', '保留上传内容及历史导出文件;部分旧版本文件可能仍在这些目录内'],
['对应版本的 EXE', '需要恢复旧版时,与该版本的数据备份配套使用'],
['部署信息', '服务 IP、端口、自定义数据目录、启动方式及备份日期'],
], [65*mm,109*mm])
add('日常备份:建议每天收工后及每次升级前执行', 'h2')
steps([
('停止服务', '通知使用人保存工作,关闭服务电脑的程序窗口。'),
('复制完整目录', '将原程序和数据目录完整复制到带日期的独立备份文件夹,例如“采购订单系统备份_20260916”。确认复制完成。'),
('核对并妥善保管', '确认备份含 instance\\po.db、对应 EXE 和所需文件。保留一份到另一台电脑或独立介质,再启动原系统恢复服务。'),
], compact=True)
add('恢复备份:先保留当前现场,再启用备份副本', 'h2')
steps([
('停止当前服务并留副本', '完整保存当前目录,避免恢复操作覆盖近期数据。明确要恢复到哪一天、哪个版本。'),
('恢复匹配的一套文件', '将已确认的完整备份放到恢复目录,使用与备份匹配的 EXE。数据会回到备份时点,备份后的新操作不会自动合并。'),
('核对后开放访问', '先核对用户、订单数、最新单号、关键跟进状态和正式 PDF。涉及旧版本回退时,再按第 15 页检查。'),
], compact=True)
note('恢复前处理通知队列', '备份可能含待发送通知。由管理员在可访问内网但限制向企业微信接口发送的维护环境中首次启动,先检查并关闭通知配置,再按需恢复通知,避免旧待办被重新推送。备份中含业务及发送凭据,应仅交给受信任的管理员。')
# 15: move and rollback
section(15, '更换服务电脑与回退', '更换服务电脑时,迁移完整程序和数据。原服务停止后,再启用新主机,使所有人继续使用同一套业务记录。')
steps([
('在旧电脑停机并备份', '确认没有正在录入或审批的操作,关闭 EXE,完整复制程序目录及实际数据目录。'),
('复制到新服务电脑', '把备份副本复制到新电脑的固定可写目录。确认 instance\\po.db、session.key、uploads、exports 和程序文件都已带上;设置过 PO_DATA_DIR 的须调整为新电脑的实际路径。'),
('先做本机核对', '在新电脑启动 EXE,用原账号登录,核对订单数量、最新单号、审批和跟进记录、历史 PDF。完成核对前让旧电脑保持停机。'),
('更新网络入口', '确认新电脑内网 IP 与实际端口,配置局域网访问权限,更新员工收藏夹以及系统“通知配置”中的内网访问地址。'),
('手机与通知联调', '手机连接公司 Wi-Fi 后打开订单。若启用企业微信,确认群通知链接指向新主机,再通知员工恢复使用。'),
])
note('换电脑后又出现“首次设置管理员”', '这通常表示当前程序没有读到原数据库。先停程序,核对 EXE 所在目录、instance\\po.db 是否完整,以及自定义数据目录设置。确认原数据位置后再启动;重新创建管理员不会找回原订单。')
add('升级失败,需要回退时', 'h2')
table(['顺序', '操作'], [
['1 停止新版', '保存错误信息与升级后目录副本,确认没有继续写入业务数据'],
['2 找到成套备份', '使用升级前备份的旧 EXE 和旧数据库,完整恢复到独立目录'],
['3 检查通知与数据', '按第 14 页处理通知队列,核对账号、最新单号和关键 PDF 后再开放访问'],
], [39*mm,135*mm])
add('回退时,旧程序应与升级前数据配套;已经升级到 v4 的数据库不能直接交给旧程序继续维护。若升级后已有新业务,应先与业务负责人确认恢复范围和补录安排。', 'small')
add('自动升级备份通常位于 instance 中,名称类似 po.db.pre-upgrade-时间.bak。它是数据库备份;完整恢复还应保留对应程序、密钥及所需上传 / 导出文件。', 'small')
# 16: FAQ and commissioning
section(16, '常见问题与上线核对', '先判断是程序未启动、访问地址不对,还是账号 / 订单状态不满足操作条件。涉及恢复或更换数据库时,先保存当前目录副本。')
table(['现象', '优先检查'], [
['本机也打不开', 'EXE 是否仍在运行;程序窗口是否报错;访问端口是否与窗口显示一致'],
['本机可用,手机打不开', '手机是否连接公司 Wi-Fi;是否使用主机 IP 而非 127.0.0.1;防火墙和网络隔离是否放行'],
['换电脑后订单不见了', '是否只复制了 EXE;原 instance\\po.db 是否迁移;是否误用了另一数据目录'],
['看不到审批或跟进按钮', '账号角色是否正确;制单人不能审批自己订单;付款和交货跟进须在审批通过后登记'],
['提示订单已更新 / 页面过期', '刷新页面核对最新状态后再操作;他人修改后,旧页面不能覆盖新记录'],
['已收票却不在待付款分类', '是否审批通过、当前账号可见;付款是否已付清、仍未登记,或变更后尚未核对'],
['登记部分付款保存失败', '实际付款日期是否填写、是否晚于今天;回退付款状态是否填写更正原因'],
['企业微信未收到通知', '按第 10-12 页核对群设置、完整 Webhook、启用状态、外网连接及最近通知状态'],
], [55*mm,119*mm])
add('交付使用前,逐项核对', 'h2')
table(['核对项目', '通过标准'], [
['账号与分工', '管理员、制单员、审批员可以登录;管理员能审批他人订单'],
['完整业务流程', '测试单提交、盖章、下载 PDF、登记部分及全部付款 / 交货均正常'],
['网络与持久保存', '另一台电脑和公司 Wi-Fi 手机可访问;重启后账号、订单、PDF 保留'],
['备份与升级', '已记录数据目录、备份位置、服务地址,已有升级前完整备份'],
['企业微信(启用时)', '真实群收到测试消息;手机点链接能登录并回到订单'],
], [55*mm,119*mm])
add('建议由管理员另行记录:服务电脑名称、内网地址、实际数据目录、备份位置、最近备份日期及负责人。', 'small')
add('本手册依据 v4 当前界面与运行说明编制。v4 已完成 76 项回归及本机 EXE 验收;公司实际网络与企业微信环境仍按上表现场核对。', 'small')
def page_frame(canvas, doc):
page = doc.page
canvas.saveState()
if page > 1:
canvas.setFont('Manual', 8)
canvas.setFillColor(GRAY)
canvas.drawString(18*mm, A4[1]-13*mm, '博特维采购订单系统 / 使用与升级手册')
canvas.drawRightString(A4[0]-18*mm, A4[1]-13*mm, 'v4.0')
canvas.setStrokeColor(LINE)
canvas.line(18*mm, A4[1]-16*mm, A4[0]-18*mm, A4[1]-16*mm)
canvas.setFont('Manual', 8)
canvas.setFillColor(GRAY)
canvas.drawString(18*mm, 11*mm, '苏州博特维智能科技有限公司 · 2026-09-16')
canvas.drawRightString(A4[0]-18*mm, 11*mm, '%02d / %02d' % (page, len(TITLES)))
canvas.restoreState()
output.parent.mkdir(parents=True, exist_ok=True)
doc = ManualDoc(str(output), pagesize=A4, leftMargin=18*mm, rightMargin=18*mm,
topMargin=23*mm, bottomMargin=20*mm, pageCompression=1,
title='博特维采购订单系统 v4 使用与升级手册', author='苏州博特维智能科技有限公司',
subject='安装、局域网、制单审批、企业微信配置、付款交货跟进、升级、备份与恢复')
doc.build(story, onFirstPage=page_frame, onLaterPages=page_frame)
import pymupdf
with pymupdf.open(output) as pdf:
assert len(pdf) == len(TITLES), 'Unexpected page overflow: %s pages' % len(pdf)
assert [item[2] for item in pdf.get_toc()] == list(range(1,len(TITLES)+1)), 'Chapter/page mismatch'
links = [link for page in pdf for link in page.get_links()]
assert sum(link['kind'] == pymupdf.LINK_GOTO for link in links) == 14, 'Missing contents links'
assert {link['uri'] for link in links if link['kind'] == pymupdf.LINK_URI} == {
'https://cloud.tencent.com/document/product/1759/128391',
'https://cloud.tencent.com/document/product/1551/84530',
'https://cloud.tencent.com/document/product/248/50413',
}, 'Missing source links'
print(output)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--screenshots', type=Path, default=ROOT/'docs/manual/screenshots')
parser.add_argument('--output', type=Path, default=ROOT/'output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf')
args = parser.parse_args()
build(args.screenshots, args.output)
+65
View File
@@ -0,0 +1,65 @@
"""Run an isolated copy of the EXE, with no Python installation on its PATH."""
import os
import shutil
import socket
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
class PackagedServer:
def __init__(self, executable, directory):
source = Path(executable).resolve(strict=True)
self.directory = Path(directory).resolve()
self.executable = self.directory / source.name
shutil.copy2(source, self.executable)
self.cwd = self.directory / '不同启动目录'
self.cwd.mkdir()
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
self.port = sock.getsockname()[1]
self.base = 'http://127.0.0.1:%s' % self.port
self.process = None
self.log = None
self.http = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def start(self):
env = {key: value for key, value in os.environ.items()
if not key.upper().startswith(('PO_', 'PYTHON', 'VIRTUAL_ENV'))}
windows = Path(env.get('SystemRoot', r'C:\Windows'))
env.update(PO_HOST='127.0.0.1', PO_OPEN_BROWSER='0',
PATH=os.pathsep.join((str(windows / 'System32'), str(windows))))
self.log = (self.directory / 'exe.log').open('ab')
self.process = subprocess.Popen([str(self.executable), str(self.port)],
cwd=self.cwd, env=env, stdin=subprocess.DEVNULL,
stdout=self.log, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW)
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
if self.process.poll() is not None:
raise RuntimeError('EXE exited during startup; see %s' % self.log.name)
try:
with self.http.open(self.base + '/login', timeout=1) as response:
if response.status == 200:
return
except (urllib.error.URLError, TimeoutError, ConnectionError):
pass
time.sleep(0.2)
raise TimeoutError('EXE startup timed out; see %s' % self.log.name)
def close(self):
if self.process is not None and self.process.poll() is None:
# A one-file PyInstaller app has a launcher and a server child.
# Terminate only the process tree created by this test.
subprocess.run(['taskkill', '/PID', str(self.process.pid), '/T', '/F'],
check=True, capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW)
self.process.wait(timeout=15)
if self.log:
self.log.close()
def restart(self):
self.close()
self.start()
+40 -6
View File
@@ -1,6 +1,6 @@
# 采购订单系统 v3:运行、升级与验收
# 采购订单系统 v4:运行、升级与验收
当前支持采购订单生成、单次内部审批、企业微信群审批通知和公司 Wi-Fi 内手机审批。未接入金额分级、组织架构或第三方电子签章。
当前支持采购订单生成、单次内部审批、企业微信群审批通知、公司 Wi-Fi 内手机审批,以及付款、收票和交货跟进。未接入金额分级、组织架构或第三方电子签章。
## 新电脑首次运行
@@ -15,7 +15,7 @@
多台电脑协作时,在一台固定电脑运行服务,其他电脑访问这台主机的局域网 IP 和实际端口。不要每人启动一套独立 EXE,否则各自数据库互不相通。关闭服务主机的程序会使其他电脑暂时无法访问。
已有 PyInstaller 配置可继续用于重新打包;本次源码修复不等于旧 EXE 已更新。运行新版 EXE 前须重新构建并验收。服务若经过 HTTPS 代理,可设置 `PO_HTTPS=1` 启用 Secure Cookie;纯 HTTP 局域网环境不要设置此项。
单文件 EXE 位于 `dist/博特维采购订单系统_单文件版.exe`,打包步骤见 README。使用时无需安装 Python,双击默认自动打开浏览器;首次初始化口令位于 EXE 同目录的 `instance/setup.token`。可设置 `PO_OPEN_BROWSER=0` 禁用自动打开浏览器。旧 EXE 不会因源码更新而自动升级。服务若经过 HTTPS 代理,可设置 `PO_HTTPS=1` 启用 Secure Cookie;纯 HTTP 局域网环境不要设置此项。
## 升级已有数据
@@ -41,6 +41,26 @@
## 操作规则
### 管理员兼任审批员
管理员账号自带审批权限,可以由同一个人兼任管理员和审批员,无需另建审批账号。管理员可以审批其他账号制的订单;自己制的订单仍需另一位审批人员完成审批。
### 付款、收票和交货跟进
- 审批通过后,在订单详情的“付款与交货跟进”中登记。制单员只维护本人订单的预付款标记、收票情况、交货情况和采购跟进备注;管理员也可维护。审批员及管理员可登记付款状态、实际付款日期和付款备注。
- 收票为未收票 / 已收票;付款为未付款 / 部分付款 / 已付款;交货为未交货 / 部分交货 / 已交货。付款和交货独立维护,因此已付款但未交货、交货后仍有尾款等情况都可记录。
- “已收票”指公司已收到供应商发票。“已收票待付款”统计已收票且付款状态为未付款或部分付款的有效订单;“需预付款”统计已标记提前付款且未付清的有效订单。预付款标记由制单员根据实际安排维护。
- 部分付款、部分交货是整单状态,不计算逐笔付款金额或逐项到货数量;具体批次、预付款比例或尾款说明可填写在备注中。登记部分付款或已付款时须填写实际付款日期。
- 工作台显示各类订单数量及最近 12 张已审批订单,点击分类可查看完整筛选列表。统计遵守用户可见范围,只计审批通过的订单;各分类可能重叠。Excel 台账包含对应状态、日期、备注和待核对提示。
- 修改保留操作人、时间及前后状态。回退已收票、已交货或付款状态时须填写更正原因;旧页面提交会要求刷新,避免覆盖他人刚保存的内容。
- 跟进更新不修改审批结论、审批章或已归档 PDF。复制订单时清空跟进信息;作废后保留跟进历史并停止修改。发起变更重审后保留已发生的事实,再次提交后提示对应经办人重新核对;核对前不将旧标记计入对应完成或待付款分类。
### v3 升级到 v4
先关闭旧程序并备份原数据目录,再在原服务电脑上替换 EXE。启动时会自动备份数据库并增加跟进字段,保留账号、密码、审批、日志和归档 PDF。旧订单的付款、收票和交货情况显示“未登记”,由经办人补录,避免把未记录的历史情况误认为未付款或未交货。更新只需在服务主机进行,其他电脑和手机继续使用原内网地址。
### 制单和审批
- 制单员只可访问自己的订单,审批员和管理员可查看订单全集;读取、复制、PDF、台账和统计遵守一致的数据范围。
- 制单员及管理员可制单。审批员一次确认即可完成审批,但不能审批自己制的订单;管理员也遵守分工限制。
- 驳回只能由当前审批环节的合格人员执行,并必须填写原因。
@@ -123,13 +143,27 @@ python tools/browser_smoke.py
## 本轮验证结果(2026-09-16)
- 65 项隔离回归用例全部通过,覆盖认证、权限、审批、精度、导入、账号维护、并发、迁移、备份恢复,以及通知事务一致性、失败重试、过期跳过、并发领取、凭据保护和登录回跳。
- 76 项隔离回归用例全部通过,覆盖认证、权限、审批、精度、导入、账号维护、并发、迁移、备份恢复、付款交货跟进,以及通知事务一致性、失败重试、过期跳过、并发领取、凭据保护和登录回跳。
- Node.js 金额预览验证通过:逐行舍入合计、半分四舍五入、四位单价及非法数值处理。
- Microsoft Edge 无界面浏览器完成首次设置、用户创建、从订单链接登录及首次改密、采购单和委外单的手机尺寸单次审批、PDF 下载、通知配置和中文 XLSX 原生日期导入,未发现页面脚本错误。320、390、768 像素宽度下工作台、列表及订单页无整页横向溢出;宽表格可在卡片内滑动。
- 最新浏览器记录位于 `tools/out/ui-check-yiosqkq7/`。模拟发送收到 1 条测试消息与 2 条批准结果,2 条已经完成的待办被跳过;未发出真实企业微信消息。
- 源码浏览器记录位于 `tools/out/ui-check-xiftbuej/`。模拟发送收到 1 条测试消息与 2 条批准结果,2 条已经完成的待办被跳过;未发出真实企业微信消息。
- PDF 最终样张已逐页检查。修正单价精度、大写金额尾缀、签署区换行和印章排字;调整委外规格列后,30 行验收样本为 2 页,大小写合计保持在同一页。
- Python 编译检查及 `git diff --check` 通过。新增 v2 到 v3 升级验证、旧审核入口保护、并发审批单次归档、PDF 生成失败回滚与单枚采购专用章检查。
- 去掉章内审批员和日期后,两类订单的审批归档用例及手机浏览器流程再次通过;已检查页面预览和 PDF 章面,章外签署信息与审批记录保留。
- 章面号码为 `3205070994140`,公司名按参考图改为细长仿宋字形,号码缩小并收紧排列。两类订单的审批归档用例及手机浏览器流程通过,页面与 PDF 样张已经渲染检查;图章放大图位于上述浏览器记录目录的 `seal-detail.png`。订单税号保持原值。
验证使用临时数据,没有对实际业务数据库执行升级。本轮交付为源码修改,尚未进行 EXE 重新打包或真实局域网多机验收。企业微信测试使用模拟发送,真实 Webhook、公司 Wi-Fi 和手机企业微信客户端仍须按上述接入步骤现场联调;复杂审批规则和第三方签章平台尚未接入。
### v4 付款交货跟进及 EXE 验收(2026-09-16)
- 新增 10 项跟进用例及 1 项 v3 升级用例,76 项回归全部通过。核对了角色与订单权限、越权字段、CSRF、旧页面冲突、更正原因、工作台分类、台账导出、复制清空、变更后分角色核对和归档 PDF 保持不变。
- v3 升级到 v4 前自动备份,保留账号会话版本、审批状态、订单版本号、历史 PDF 及摘要;历史跟进状态初始化为未登记,重复启动不重复迁移。
- 新版单文件 EXE 位于 `dist/v4/博特维采购订单系统_单文件版.exe`。实际使用 EXE 完成两类订单的建单审批、制单员跟进、审批员部分及全部付款登记、工作台数量及筛选检查,320、390、768 像素宽度下无整页横向溢出。
- EXE 测试记录位于 `tools/out/ui-check-ni65n2lj/`,包含工作台、手机跟进表单和 PDF 样张。重启后付款交货标记保留,正式 PDF 摘要保持一致,通知发送保持关闭;全部测试仅使用临时数据。
### v3 Windows EXE 验收(2026-09-16)
- 使用独立 Python 3.11.9 / PyInstaller 6.22.0 环境,按项目依赖范围生成 Windows 64 位单文件 EXE。打包环境依赖检查通过;使用相同运行库版本的 65 项回归用例全部通过。
- 对 EXE 的独立副本实际执行首次设置、账号创建及首次改密、采购单和 30 行委外单的手机尺寸审批、正式 PDF 下载、中文 XLSX 日期导入、模板和台账下载;静态资源加载正常,无页面脚本错误。
- EXE 在中文路径下、不同工作目录及不含 Python 的 PATH 中启动成功,数据保存在 EXE 同目录。关闭并重启后,登录仍有效,两份归档 PDF 的 SHA-256 与重启前一致;数据库完整性检查通过。
- 测试记录及 PDF 样张位于 `tools/out/ui-check-vxzzkrvg/`。测试副本始终关闭企业微信发送,通知队列为空;测试进程已停止,交付的 EXE 目录不含测试数据库。
验证使用临时数据,没有对实际业务数据库执行升级。EXE 已完成本机验收,尚未进行真实局域网多机验收。企业微信源码测试使用模拟发送,EXE 测试关闭通知;真实 Webhook、公司 Wi-Fi 和手机企业微信客户端仍须按上述接入步骤现场联调;复杂审批规则和第三方签章平台尚未接入。