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