新增Win7离线构建并调整PDF采购章及供需方签章位置

This commit is contained in:
reddaniel110
2026-09-17 10:41:03 +08:00
parent 768b4705dc
commit 62cd117379
15 changed files with 656 additions and 26 deletions
+2
View File
@@ -37,6 +37,8 @@ cd ..
安装开发验收依赖后,可执行 `python tools/browser_smoke.py --exe dist/博特维采购订单系统_单文件版.exe`。脚本在临时目录中复制并运行 EXE,验证页面、建单、手机尺寸审批、Excel 导入导出、带章 PDF 及重启后的归档一致性。EXE 测试不启用企业微信发送;测试数据和截图位于 `tools/out/ui-check-*`。
Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/README_WIN7.md)。离线打包包包含 Python 3.8.10 官方安装程序、32/64 位离线依赖、两步构建脚本和自检;请在目标服务器生成 EXE。Win7 环境不要执行上面的 Python 3.11 打包命令。
## 主要能力
- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "PYTHONHOME="
set "PYTHONPATH="
set "PO_PYTHON=%~dp0.python38\python.exe"
set "PO_INSTALLER=%~dp0installers\python-3.8.10.exe"
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "PO_INSTALLER=%~dp0installers\python-3.8.10-amd64.exe"
if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "PO_INSTALLER=%~dp0installers\python-3.8.10-amd64.exe"
if exist "%PO_PYTHON%" goto ready
echo Installing the bundled Python 3.8.10. Windows 7 SP1 is required.
echo If Windows requests a restart, restart and run this script again.
start /wait "" "%PO_INSTALLER%" /passive InstallAllUsers=0 TargetDir="%~dp0.python38" Include_launcher=0 InstallLauncherAllUsers=0 PrependPath=0 Include_doc=0 Include_test=0 Include_tcltk=0 Shortcuts=0 AssociateFiles=0
if errorlevel 1 goto failed
:ready
if not exist "%PO_PYTHON%" goto failed
"%PO_PYTHON%" "%~dp0win7\build.py" setup
if errorlevel 1 goto failed
echo.
echo Setup passed. Run 02_build_exe.cmd next.
pause
exit /b 0
:failed
echo.
echo SETUP FAILED. See README_WIN7.txt and the message above.
echo Do not install the modern Python 3.11 build on Windows 7.
pause
exit /b 1
+20
View File
@@ -0,0 +1,20 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "PYTHONHOME="
set "PYTHONPATH="
if not exist "%~dp0.buildenv\Scripts\python.exe" goto missing
"%~dp0.buildenv\Scripts\python.exe" "%~dp0win7\build.py" build
if errorlevel 1 goto failed
echo.
echo EXE is in the new dated subfolder under dist. See BUILD_INFO.json.
pause
exit /b 0
:missing
echo Run 01_install_env.cmd successfully first.
:failed
echo BUILD FAILED. Check the messages and build log. Keep the old server unchanged.
pause
exit /b 1
+67
View File
@@ -0,0 +1,67 @@
# Windows 7 服务器离线打包说明
本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。
2026-09-17 排版更新:采购专用章直径增加 30%,固定在右侧需方签章空位;供方和需方签章区之间留出 20 毫米间隔。采购单与委外订单均已调整,示例见 `samples/`。本次 PDF 模板版本为 `po-v4`。
升级后新审批生成的正式 PDF 使用新排版。已审批归档的 PDF 保留原版;需要采用新版的订单,请通过系统的“变更重审”流程重新审批。使用本包重新解压、安装环境并生成 EXE 后,按下文备份和替换服务程序。
## 1. 先确认系统
- 在“计算机”上点右键 → “属性”,确认是 Windows 7 **Service Pack 1**,并查看系统类型是 32 位还是 64 位。脚本会自动按系统类型选择对应安装程序和依赖。
- Python 3.8 需要 Windows 7 的 KB2533623 或替代更新(如 KB3063858)所提供的 DLL 加载支持,以及 Universal C Runtime。安装 Python 时若提示系统补丁、运行库或重启,请先按提示补齐。补丁可能需要网管从微软获取,本包不自动更改系统补丁或防火墙。
- 建议把整个压缩包解压到较短的固定路径,例如 `C:\PO_Win7Build`。不要在压缩软件内直接运行脚本,也不要放到正在保存正式订单的目录中。
- 本包按 Win7 兼容依赖制作,当前电脑上的验证不等同于真实 Win7 验收;在目标服务器打包和自检通过后再投入使用。Win7 与 Python 3.8 已结束官方维护,按公司局域网范围部署。
## 2. 两步生成 EXE
1. 双击 `01_install_env.cmd`。
- 使用包内 Python 3.8.10 安装程序;安装到包内 `.python38`,不把它加入系统 PATH,也不替换其他 Python 版本。
- 从本包 `wheels/x64` 或 `wheels/x86` 离线安装到 `.buildenv`,不连接 pip 源、不临时升级依赖。
- 自动检查密码哈希、中文 PDF、制单审批、部分/全部付款交货、Excel 台账和 HTTP 服务。自检仅使用临时数据库,通知不开启。
- 看到 `SETUP COMPLETE` 后关闭窗口。失败时保留报错内容。
2. 双击 `02_build_exe.cmd`。
- 再次核对文件校验值、Python 与依赖版本,然后构建单文件程序。
- 打包期间可能数分钟没有新输出;详细日志在 `build/时间-位数/build.log`。
- 生成后自动在隔离目录启动一次 EXE,检查登录与初始化入口,并关闭该测试进程。
- 看到 `BUILD COMPLETE` 后,在 `dist/时间-位数/` 取出 **`PO_Server_Win7.exe`**。同目录还有 `BUILD_INFO.json` 和使用说明。
32 位 Windows 生成 32 位 EXE,64 位 Windows 生成 64 位 EXE。每次构建写到新的日期目录,不覆盖已有程序或业务数据。请保持本包原始源码及依赖不变,才能通过校验;需要修改源码时由维护人员重新制作本包。
## 3. 在服务器上使用
把生成的 EXE 放入固定服务目录,例如 `D:\PO_Server`,双击运行,按窗口显示的端口访问。首次部署时在此目录的 `instance/setup.token` 查看初始化口令。原系统升级时,先停机并完整备份,再替换程序,保留原 `instance`、`uploads`、`exports`;设置过 `PO_DATA_DIR` 的继续指向原数据目录。
同局域网的员工电脑和手机继续用浏览器访问服务电脑的内网 IP 与实际端口,例如 `http://192.168.1.100:8080`。建议在员工的较新电脑或手机上操作页面;服务电脑只负责运行 EXE,Win7 自带的 IE 不是本项目已验收浏览器。
首次正式使用前,至少用两个不同账号完成:创建测试订单 → 提交 → 审批盖章 → 下载中文 PDF → 登记部分和全部付款/交货 → 重启程序确认数据仍在。企业微信另按手册第 10-12 页配置并在真实公司 Wi-Fi 下验证。测试完成前,不删除旧程序和原数据备份。
## 4. 常见问题
| 现象 | 处理 |
| --- | --- |
| Python 安装提示需要 SP1、更新或重启 | 先检查 Windows 7 SP1、DLL 加载更新及 UCRT。完成更新并重启,再运行第一个脚本 |
| 缺少 api-ms-win-crt、VCRUNTIME140、找不到程序入口点 | 检查系统运行库和更新;若自检提示具体 DLL,保留完整名称及错误信息。不要从陌生网站下载单个 DLL 覆盖系统目录 |
| 文件校验失败 | 重新完整解压本包;确认杀毒软件没有隔离文件、源码和 wheels 未被修改 |
| 路径过长、权限不足 | 重新解压到较短、可写的本地路径。不要移动已经建立的 `.buildenv`,在新目录重跑第一个脚本 |
| 自检密码或 PDF 失败 | 保留完整错误及依赖信息,暂不部署。包内检查包括新版账号 scrypt 哈希及本机中文字体加载 |
| 打包失败 | 查看新建的 `build/时间-位数/build.log`;不要改用普通 `requirements-build.txt`,它用于较新的 Windows/Python |
| EXE 自检失败 | 不替换正式服务;检查错误内容、系统补丁和杀毒隔离记录 |
| 本机可用,手机打不开 | 按手册核对实际 IP、端口、公司 Wi-Fi 和防火墙。通知配置中的地址也要同步 |
## 5. 兼容依赖与维护说明
本包使用主线 v4 业务源码,并包含上述 PDF 排版更新。兼容依赖完全固定,使用 Python 3.8.10、PyInstaller 5.13.2、Flask 3.0.3、Werkzeug 3.0.6、SQLAlchemy 2.0.44、ReportLab 4.2.5、Pillow 10.4.0 等。完整列表在 `win7/requirements-win7.txt`。
Waitress 使用本项目的 **3.0.2+win7.1** 兼容 wheel。HTTP 服务及安全修复代码与官方 3.0.2 保持逐字节一致,仅为命令行入口的名称解析增加 Python 3.8 回退,并修改版本元数据、重算 wheel 校验记录;原许可证保留。这样保留 3.0.1/3.0.2 的修复,没有降到旧的 3.0.0。上游官方支持范围仍为 Python 3.9+,这是本项目单独测试维护的兼容组合,不代表上游承诺 Win7 支持。原始 wheel 和重打包脚本随包提供。
`PACKAGE_INFO.json` 记录源代码提交与验证范围;`SHA256SUMS.json` 记录本包文件校验值。构建输出的 `BUILD_INFO.json` 记录实际操作系统、Python、位数和 EXE SHA-256。最终以你的服务器上的自检和现场流程验收为准。
## 官方参考
- Python 3.8.10 及官方安装程序:https://www.python.org/downloads/release/python-3810/
- Python 3.8 Windows 系统说明:https://docs.python.org/3.8/using/windows.html
- PyInstaller 5.13.2 的 Windows 构建与运行库说明:https://pyinstaller.org/en/v5.13.2/usage.html#windows
- Waitress 3.0.2 变更记录:https://docs.pylonsproject.org/projects/waitress/en/stable/
现有 PDF 手册描述 v4 常规 Windows 64 位版本的操作流程。**Win7 安装及打包环境以本文件为准**,无需按通用手册安装 Python 3.11 或 PyInstaller 6。
+122
View File
@@ -0,0 +1,122 @@
"""Offline setup and EXE build, intended to run on the Windows 7 server."""
import argparse
from datetime import datetime
import hashlib
import importlib.metadata
import json
import os
from pathlib import Path
import platform
import shutil
import struct
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
ARCH = 'x64' if struct.calcsize('P') == 8 else 'x86'
def run(args, **kwargs):
print('>', subprocess.list2cmdline([str(x) for x in args]), flush=True)
subprocess.run([str(x) for x in args], check=True, **kwargs)
def verify():
manifest = json.loads((ROOT/'SHA256SUMS.json').read_text(encoding='utf-8'))
for name, digest in manifest.items():
path = (ROOT/name).resolve()
if ROOT.resolve() not in path.parents:
raise RuntimeError('Invalid manifest path')
if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
raise RuntimeError('File checksum mismatch: ' + name)
print('Package checksums verified.', flush=True)
def check_python():
if os.name != 'nt' or sys.version_info[:3] != (3, 8, 10):
raise RuntimeError('Use the bundled official Windows Python 3.8.10 installer first.')
version = sys.getwindowsversion()
if (version.major, version.minor) < (6, 1) or ((version.major, version.minor) == (6, 1) and version.service_pack_major < 1):
raise RuntimeError('Windows 7 SP1 or later is required.')
print('Python:', sys.version.split()[0], ARCH, '| OS:', platform.platform(), flush=True)
if (version.major, version.minor) != (6, 1):
print('NOTE: This is not Windows 7. The resulting EXE must still be built/tested on Windows 7.', flush=True)
def setup():
check_python()
verify()
env_dir = ROOT/'.buildenv'
python = env_dir/'Scripts/python.exe'
if not python.exists():
run([sys.executable, '-m', 'venv', env_dir])
wheels = ROOT/'wheels'/ARCH
options = ['--no-index', '--find-links', wheels, '--disable-pip-version-check', '--no-cache-dir']
run([python, '-m', 'pip', 'install'] + options + ['pip==25.0.1', 'setuptools==68.2.2', 'wheel==0.45.1'])
run([python, '-m', 'pip', 'install'] + options + ['-r', ROOT/'win7/requirements-win7.txt'])
run([python, '-m', 'pip', 'check'])
run([python, ROOT/'win7/selftest.py', ROOT/'po_app'])
print('SETUP COMPLETE. Run 02_build_exe.cmd next.', flush=True)
def build():
check_python()
verify()
# Running from the dedicated environment keeps modern packages out of the build.
expected = (ROOT/'.buildenv/Scripts/python.exe').resolve()
if Path(sys.executable).resolve() != expected:
raise RuntimeError('Run 02_build_exe.cmd after 01_install_env.cmd.')
for line in (ROOT/'win7/requirements-win7.txt').read_text().splitlines():
if not line or line.startswith('#'):
continue
package, pinned = line.split('==')
if importlib.metadata.version(package) != pinned:
raise RuntimeError('Dependency changed; run 01_install_env.cmd again: ' + package)
run([sys.executable, ROOT/'win7/selftest.py', ROOT/'po_app'])
build_id = datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '-' + ARCH
work = ROOT/'build'/build_id
output = ROOT/'dist'/build_id
work.mkdir(parents=True)
output.mkdir(parents=True)
env = os.environ.copy()
env['PYINSTALLER_CONFIG_DIR'] = str(work/'cache')
command = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--onefile', '--console',
'--noupx', '--name', 'PO_Server_Win7', '--distpath', output,
'--workpath', work/'temp', '--specpath', work,
'--paths', ROOT/'po_app',
'--add-data', str(ROOT/'po_app/templates') + ';templates',
'--add-data', str(ROOT/'po_app/static') + ';static',
'--hidden-import', 'sqlalchemy.dialects.sqlite',
'--hidden-import', 'openpyxl', '--hidden-import', 'xlrd',
'--collect-all', 'reportlab', ROOT/'po_app/app.py']
log_path = work/'build.log'
print('Building EXE. Build log:', log_path, flush=True)
with log_path.open('wb') as log:
subprocess.run([str(x) for x in command], cwd=ROOT, env=env,
stdout=log, stderr=subprocess.STDOUT, check=True)
exe = output/'PO_Server_Win7.exe'
run([sys.executable, ROOT/'win7/probe_exe.py', exe])
info = {'built_on': platform.platform(), 'python': sys.version, 'architecture': ARCH,
'source_commit': json.loads((ROOT/'PACKAGE_INFO.json').read_text(encoding='utf-8'))['source_commit'],
'sha256': hashlib.sha256(exe.read_bytes()).hexdigest(),
'source_selftest': 'passed', 'exe_startup_probe': 'passed',
'built_on_windows7': sys.getwindowsversion()[:2] == (6, 1)}
(output/'BUILD_INFO.json').write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding='utf-8')
shutil.copy2(ROOT/'README_WIN7.txt', output/'README_WIN7.txt')
for pdf in (ROOT/'manual').glob('*.pdf'):
shutil.copy2(pdf, output/pdf.name)
print('BUILD COMPLETE:', exe, flush=True)
print('Copy EXE to a fixed service folder. Keep the original instance/uploads/exports when upgrading.', flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['setup', 'build', 'verify'])
args = parser.parse_args()
try:
{'setup': setup, 'build': build, 'verify': verify}[args.command]()
except Exception:
import traceback
traceback.print_exc()
print('FAILED. Do not replace the running server. Check the message and build log above.', flush=True)
sys.exit(1)
+97
View File
@@ -0,0 +1,97 @@
"""Assemble the offline Win7 source kit from verified downloads in a local cache."""
import argparse
from datetime import datetime
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import zipfile
REPO = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
def assemble(cache, output):
cache, output = cache.resolve(), output.resolve()
if REPO not in output.parents:
raise ValueError('Output must be within this workspace')
if output.exists() or output.with_suffix('.zip').exists():
raise ValueError('Choose a new output directory; existing packages are not overwritten')
# Only committed application files are shipped, never runtime directories.
subprocess.run(['git', 'diff', '--exit-code', 'HEAD', '--', 'po_app'], cwd=REPO, check=True,
stdout=subprocess.DEVNULL)
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip()
names = subprocess.check_output(['git', 'ls-files', '-z', 'po_app'], cwd=REPO).decode('utf-8').split('\0')
output.mkdir(parents=True)
def copy(source, relative):
dest = output/relative
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
for name in names:
if not name:
continue
if name.endswith('.py') or name.startswith(('po_app/templates/', 'po_app/static/')):
copy(REPO/name, name)
for name in ['build.py', 'selftest.py', 'probe_exe.py', 'requirements-win7.txt', 'make_waitress_wheel.py']:
copy(HERE/name, 'win7/' + name)
for name in ['01_install_env.cmd', '02_build_exe.cmd']:
(output/name).write_bytes((HERE/name).read_text(encoding='utf-8').replace('\r\n', '\n').replace('\n', '\r\n').encode('ascii'))
(output/'README_WIN7.txt').write_text((HERE/'README_WIN7.md').read_text(encoding='utf-8'), encoding='utf-8-sig')
copy(REPO/'output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf', 'manual/博特维采购订单系统_v4_使用与升级手册.pdf')
for sample in ('采购单_签章调整样张.pdf', '委外订单_签章调整样张.pdf'):
copy(REPO/'output/pdf'/sample, 'samples/' + sample)
for name, expected_md5 in [('python-3.8.10-amd64.exe', '62cf1a12a5276b0259e8761d4cf4fe42'),
('python-3.8.10.exe', 'b355cfc84b681ace8908ae50908e8761')]:
if hashlib.md5((cache/name).read_bytes()).hexdigest() != expected_md5:
raise ValueError('Installer differs from python.org download: ' + name)
copy(cache/name, 'installers/' + name)
pins = {}
for line in (HERE/'requirements-win7.txt').read_text().splitlines():
if line and not line.startswith('#'):
name, version = line.split('==')
pins[name.lower().replace('-', '_')] = version
for arch, folder in [('x64', 'wheels64'), ('x86', 'wheels32')]:
selected = []
for wheel in (cache/folder).glob('*.whl'):
name, version = wheel.name.split('-')[:2]
if pins.get(name.lower()) == version:
copy(wheel, 'wheels/' + arch + '/' + wheel.name)
selected.append(name.lower())
if set(selected) != set(pins) or len(selected) != len(pins):
raise ValueError('Incomplete wheel set: %s, missing %s' % (arch, set(pins)-set(selected)))
copy(cache/'waitress-3.0.2-py3-none-any.whl', 'third_party/waitress-3.0.2-py3-none-any.whl')
info = {'source_commit': commit, 'created_at': datetime.now().isoformat(timespec='seconds'),
'purpose': 'Build v4 on the target Windows 7 SP1 server',
'architectures': ['x86', 'x64'], 'python': '3.8.10',
'windows7_field_verified': False,
'validation_note': 'See VALIDATION.txt for local tests; target Win7 acceptance is still required.'}
(output/'PACKAGE_INFO.json').write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding='utf-8')
if (cache/'VALIDATION.txt').exists():
copy(cache/'VALIDATION.txt', 'VALIDATION.txt')
hashes = {p.relative_to(output).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(output.rglob('*')) if p.is_file()}
(output/'SHA256SUMS.json').write_text(json.dumps(hashes, ensure_ascii=False, indent=2), encoding='utf-8')
archive = output.with_suffix('.zip')
with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as zipout:
for path in sorted(output.rglob('*')):
if path.is_file():
zipout.write(path, (Path(output.name)/path.relative_to(output)).as_posix())
with zipfile.ZipFile(archive) as zipout:
assert zipout.testzip() is None
assert all(zipout.read(output.name + '/' + name) == (output/name).read_bytes() for name in hashes)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
archive.with_suffix('.zip.sha256.txt').write_text(digest + ' ' + archive.name + '\n', encoding='ascii')
print(archive)
print('Bytes:', archive.stat().st_size, 'SHA256:', digest)
return archive
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache', type=Path, default=REPO/'tools/out/win7-prep')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
assemble(args.cache, args.output)
+60
View File
@@ -0,0 +1,60 @@
"""Repackage official Waitress 3.0.2 metadata for the tested Python 3.8 build.
The HTTP server and security fixes are kept byte-for-byte. Only the CLI name
resolver gets a Python 3.8 fallback. The local version identifies the adaptation.
"""
import base64
import csv
import hashlib
import io
from pathlib import Path
import zipfile
UPSTREAM_SHA256 = 'c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e'
VERSION = '3.0.2+win7.1'
def repackage(source, destination):
source, destination = Path(source), Path(destination)
if hashlib.sha256(source.read_bytes()).hexdigest() != UPSTREAM_SHA256:
raise ValueError('Unexpected upstream Waitress wheel checksum')
old_info, new_info = 'waitress-3.0.2.dist-info', 'waitress-%s.dist-info' % VERSION
destination.mkdir(parents=True, exist_ok=True)
target = destination / ('waitress-%s-py3-none-any.whl' % VERSION)
records = []
with zipfile.ZipFile(source) as src, zipfile.ZipFile(target, 'w', zipfile.ZIP_DEFLATED) as out:
for name in src.namelist():
if name.endswith('/RECORD'):
continue
content = src.read(name)
new_name = name.replace(old_info, new_info)
if name == 'waitress/runner.py':
assert b'import pkgutil\n' in content and b'pkgutil.resolve_name(args[0])' in content
content = content.replace(b'import pkgutil\n',
b'try:\n from pkgutil import resolve_name\nexcept ImportError:\n from pkgutil_resolve_name import resolve_name\n')
content = content.replace(b'pkgutil.resolve_name(args[0])', b'resolve_name(args[0])')
if name == old_info + '/METADATA':
content = content.replace(b'Version: 3.0.2\n', ('Version: %s\n' % VERSION).encode())
content = content.replace(b'Requires-Python: >=3.9.0\n',
b'Requires-Python: >=3.8.0\nRequires-Dist: pkgutil-resolve-name==1.3.10; python_version < "3.9"\n')
assert b'Requires-Python: >=3.8.0\n' in content
out.writestr(new_name, content)
digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b'=').decode()
records.append((new_name, 'sha256=' + digest, str(len(content))))
records.append((new_info + '/RECORD', '', ''))
record = io.StringIO(newline='')
csv.writer(record, lineterminator='\n').writerows(records)
out.writestr(new_info + '/RECORD', record.getvalue())
with zipfile.ZipFile(source) as src, zipfile.ZipFile(target) as dest:
assert all(src.read(name) == dest.read(name) for name in src.namelist()
if name.startswith('waitress/') and name != 'waitress/runner.py')
return target
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('destination', type=Path)
args = parser.parse_args()
print(repackage(args.source, args.destination))
+49
View File
@@ -0,0 +1,49 @@
"""Start the built EXE in isolation, verify setup/login, then stop its own tree."""
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import time
import urllib.request
def probe(executable):
with tempfile.TemporaryDirectory(prefix='po-win7-exe-') as temporary:
directory = Path(temporary)
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
env = {k: v for k, v in os.environ.items()
if not k.upper().startswith(('PO_', 'PYTHON', 'VIRTUAL_ENV'))}
env.update(PO_DATA_DIR=str(directory), PO_HOST='127.0.0.1', PO_OPEN_BROWSER='0')
http = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with (directory/'server.log').open('wb') as log:
process = subprocess.Popen([str(Path(executable).resolve()), str(port)], env=env,
cwd=directory, stdout=log, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW)
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError('EXE exited: ' + (directory/'server.log').read_text(errors='replace'))
try:
with http.open('http://127.0.0.1:%s/login' % port, timeout=1) as response:
if response.status == 200:
assert (directory/'instance/setup.token').is_file()
print('EXE STARTUP PASSED:', executable)
return
except (OSError, TimeoutError):
time.sleep(0.25)
raise RuntimeError('EXE did not start within 90 seconds')
finally:
if process.poll() is None:
subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'],
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW)
process.wait(timeout=20)
if __name__ == '__main__':
import sys
probe(sys.argv[1])
+32
View File
@@ -0,0 +1,32 @@
# Windows 7 compatibility environment: CPython 3.8.10 only.
# The local Waitress wheel adapts only the CLI resolver; see README.
Flask==3.0.3
Werkzeug==3.0.6
Jinja2==3.1.6
MarkupSafe==2.1.5
click==8.1.8
itsdangerous==2.2.0
blinker==1.8.2
colorama==0.4.6
SQLAlchemy==2.0.44
greenlet==3.1.1
typing_extensions==4.13.2
reportlab==4.2.5
pillow==10.4.0
chardet==5.2.0
openpyxl==3.1.5
et-xmlfile==2.0.0
xlrd==2.0.2
waitress==3.0.2+win7.1
pkgutil-resolve-name==1.3.10
importlib-metadata==8.5.0
zipp==3.20.2
PyInstaller==5.13.2
pyinstaller-hooks-contrib==2023.8
pefile==2023.2.7
pywin32-ctypes==0.2.3
altgraph==0.17.4
setuptools==68.2.2
wheel==0.45.1
pip==25.0.1
packaging==24.2
+120
View File
@@ -0,0 +1,120 @@
"""Exercise the real application with isolated data; never send notifications."""
import argparse
from datetime import date
import io
import os
from pathlib import Path
import re
import secrets
import sys
import tempfile
import threading
import urllib.request
def run(source, artifacts=None):
import openpyxl
from PIL import Image
from waitress import create_server
from werkzeug.security import generate_password_hash, check_password_hash
password = 'Win7-Test-' + secrets.token_hex(10)
hashed = generate_password_hash(password)
assert check_password_hash(hashed, password), 'Password hashing failed'
# Also test the scrypt parameters generated by newer Windows deployments.
assert check_password_hash(generate_password_hash(password, method='scrypt:32768:8:1'), password)
Image.new('RGB', (3, 3)).save(io.BytesIO(), format='PNG')
with tempfile.TemporaryDirectory(prefix='po-win7-check-') as temporary:
os.environ['PO_DATA_DIR'] = temporary
os.environ.pop('PO_SECRET_KEY', None)
os.environ.pop('PO_HTTPS', None)
sys.path.insert(0, str(source.resolve()))
import app as module
from models import User
module.app.config['TESTING'] = True
assert (Path(temporary)/'instance/setup.token').is_file()
with module.Session() as session:
for name, role in [('maker', 'maker'), ('admin', 'admin')]:
session.add(User(username=name, name=name, role=role, active=True,
must_change_password=False, session_version=1, password_hash=hashed))
session.commit()
def post(client, path, data):
client.get('/login')
with client.session_transaction() as session:
token = session['csrf_token']
return client.post(path, data=dict(data, csrf_token=token))
clients = {}
for name in ('maker', 'admin'):
client = module.app.test_client()
assert post(client, '/login', {'username': name, 'password': password}).status_code == 302
clients[name] = client
def version(oid):
with module.Session() as session:
return session.get(module.Order, oid).row_version
def action(actor, oid, endpoint, **fields):
fields['row_version'] = str(version(oid))
return post(clients[actor], '/orders/%d/%s' % (oid, endpoint), fields)
try:
for kind in ('purchase', 'outsourcing'):
response = post(clients['maker'], '/orders/new',
{'doc_type': kind, 'request_key': secrets.token_hex(16)})
assert response.status_code == 302
oid = int(re.search(r'/orders/(\d+)', response.location).group(1))
response = action('maker', oid, 'edit', supplier_name='Win7 test supplier',
our_company='苏州博特维智能科技有限公司', payment_terms='月结',
delivery_place='苏州', order_date=date.today().isoformat(),
item_code='TEST-001', item_spec='测试物料', item_unit='件',
item_qty='2', item_price='12.3456', item_date=date.today().isoformat(),
item_remark='', intent='submit')
assert response.status_code == 302, response.get_data(as_text=True)
assert action('maker', oid, 'approve', stamped='1').status_code == 403
assert action('admin', oid, 'approve', stamped='1').status_code == 302
pdf = clients['maker'].get('/orders/%d/pdf' % oid).data
assert pdf.startswith(b'%PDF-') and b'%%EOF' in pdf
if artifacts:
artifacts.mkdir(parents=True, exist_ok=True)
(artifacts/(kind + '.pdf')).write_bytes(pdf)
for payment, delivery in [('partial', 'partial'), ('paid', 'delivered')]:
assert action('maker', oid, 'follow-up', needs_prepayment='1',
invoice_status='received', delivery_status=delivery,
fulfillment_note='离线环境自检', reason='').status_code == 302
assert action('admin', oid, 'payment', payment_status=payment,
payment_date=date.today().isoformat(), payment_note='', reason='').status_code == 302
assert clients['maker'].get('/orders/%d/pdf' % oid).data == pdf
print('PASS:', kind, 'login, submit, approval, PDF, partial/full follow-up, archive')
workbook = openpyxl.load_workbook(io.BytesIO(clients['admin'].get('/export/ledger').data))
assert workbook.active.max_row == 3
workbook.close()
assert clients['admin'].get('/orders?tracking=completed').status_code == 200
assert clients['admin'].get('/settings/notifications').status_code == 200
# Test a real HTTP socket using the exact Waitress compatibility wheel.
server = create_server(module.app, host='127.0.0.1', port=0, threads=2)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
try:
http = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with http.open('http://127.0.0.1:%s/login' % server.effective_port, timeout=10) as response:
assert response.status == 200
response.read()
finally:
server.task_dispatcher.shutdown()
server.close()
thread.join(timeout=10)
print('PASS: Excel, dashboard, notification settings, Waitress HTTP')
finally:
module.engine.dispose()
print('SELFTEST PASSED. Only temporary data was used. No group messages sent.')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('--artifacts', type=Path)
args = parser.parse_args()
run(args.source, args.artifacts)
+3 -2
View File
@@ -13,7 +13,7 @@ from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_appro
can_reject, clear_approval, creation_request, get_order, make_item,
next_order_no, recalc, remember_creation, save_form, snapshot, submit,
validate_order)
from pdf_export import build_pdf, seal_layout
from pdf_export import PDF_TEMPLATE_VERSION, build_pdf, seal_layout
from order_tracking import can_follow, can_record_payment
from security import current_user, db, login_required, role_required
@@ -131,7 +131,8 @@ def register_order_routes(app):
version = db.query(OrderVersion).filter_by(order_id=oid, revision=order.revision).one()
version.pdf_data = pdf_bytes(order)
version.pdf_sha256 = hashlib.sha256(version.pdf_data).hexdigest()
version.template_version = 'po-v3-legacy-current' if version.template_version == 'legacy-current' else 'po-v3'
version.template_version = (PDF_TEMPLATE_VERSION + '-legacy-current'
if version.template_version == 'legacy-current' else PDF_TEMPLATE_VERSION)
add_log(order, '审批通过并归档正式 PDF', user, request.form.get('comment'), old)
db.commit()
flash('审批通过,正式 PDF 已归档', 'ok')
+51 -22
View File
@@ -60,6 +60,8 @@ STATUS_WATERMARK = {
SEAL_COLOR = colors.HexColor("#C8102E")
SEAL_NUMBER = "3205070994140"
PDF_TEMPLATE_VERSION = "po-v4"
PDF_SEAL_SIZE = 32 * 1.3 * mm
def _seal_arc(text, radius, start, span, font, size, bottom=False,
@@ -112,7 +114,7 @@ def seal_layout(company, seal_number=SEAL_NUMBER):
class SealFlowable(Flowable):
"""公司名和印章号码环绕一圈的采购章,不含个人及日期。"""
def __init__(self, company, title, seal_number=SEAL_NUMBER, size=32 * mm):
def __init__(self, company, title, seal_number=SEAL_NUMBER, size=PDF_SEAL_SIZE):
Flowable.__init__(self)
self.layout = seal_layout(company, seal_number)
self.title = title or u"采购专用章"
@@ -153,6 +155,38 @@ class SealFlowable(Flowable):
c.restoreState()
class ConfirmationFlowable(Flowable):
"""Keep each party's label and signing space in the same fixed-size cell."""
def __init__(self, party, width, seal=None):
Flowable.__init__(self)
self.label = u"%s确认(盖章):" % party
self.width = width
self.height = PDF_SEAL_SIZE + 4 * mm
self.seal = seal
def draw(self):
c = self.canv
c.saveState()
font_size = 9.5
center_y = self.height / 2
baseline = center_y - font_size * 0.35
c.setFont(FONT_BODY, font_size)
c.setFillColor(colors.black)
c.drawString(0, baseline, self.label)
signing_start = pdfmetrics.stringWidth(self.label, FONT_BODY, font_size) + 2 * mm
signing_end = self.width - 1 * mm
if self.seal:
# Center the seal in the buyer's signing space, next to its label.
self.seal.drawOn(c, (signing_start + signing_end - self.seal.width) / 2,
center_y - self.seal.height / 2)
else:
c.setStrokeColor(colors.HexColor("#888888"))
c.setLineWidth(0.4)
c.line(signing_start, baseline - 1.5, signing_end, baseline - 1.5)
c.restoreState()
def _find_font(candidates):
for d in FONT_DIRS:
for name in candidates:
@@ -379,29 +413,24 @@ def build_pdf(order, items, out_path, watermark=None):
sign_line = u"制单:%s\n审批:%s" % (
stamp(order.created_by_name, order.created_at),
stamp(order.approved_by, order.approved_at))
confirm_line = (u"供方确认(盖章):__________________ "
u"需方确认(盖章):__________________")
# One final approval produces one procurement seal. Historical archived
# PDFs are served unchanged by order_pdf and never regenerated here.
if order.approved_by:
seal_cell = SealFlowable(order.our_company or COMPANY_NAME, u"采购专用章")
sign_tbl = Table(
[[P(sign_line, styles["sign"]), seal_cell],
[P(confirm_line, styles["sign"]), P("", styles["sign"])]],
colWidths=[128 * mm, 58 * mm])
sign_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (0, -1), "TOP"),
("VALIGN", (1, 0), (1, -1), "CENTER"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
story.append(KeepTogether(sign_tbl))
else:
story.append(P(sign_line, styles["sign"]))
story.append(Spacer(1, 3 * mm))
story.append(P(confirm_line, styles["sign"]))
seal = (SealFlowable(order.our_company or COMPANY_NAME, u"采购专用章")
if order.approved_by else None)
party_width = 83 * mm
confirmations = Table(
[[ConfirmationFlowable(u"供方", party_width), "",
ConfirmationFlowable(u"需方", party_width, seal)]],
colWidths=[party_width, 20 * mm, party_width])
confirmations.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
story.append(KeepTogether([P(sign_line, styles["sign"]),
Spacer(1, 3 * mm), confirmations]))
return story
doc.build(make_story())
+3 -2
View File
@@ -78,14 +78,15 @@ def test_full_flow_and_frozen_pdf(app, clients, kind, n):
assert clients['maker'].get('/orders/%s/pdf' % oid).data == response.data
import pymupdf
with pymupdf.open(stream=response.data, filetype='pdf') as doc:
content = ''.join(p.get_text() for p in doc)
# Older PDF readers may split adjacent Chinese glyphs into lines.
content = re.sub(r'\s+', '', ''.join(p.get_text() for p in doc))
assert content.count('采购专用章') == 1
assert '审核专用章' not in content and '批准专用章' not in content
with app.Session() as db:
v = db.query(OrderVersion).filter_by(order_id=oid, revision=1).one()
assert v.pdf_sha256 == hashlib.sha256(response.data).hexdigest()
assert len(json.loads(v.content_json)['items']) == n
assert v.template_version == 'po-v3'
assert v.template_version == 'po-v4'
assert save(app, clients['maker'], oid).status_code == 403
assert clients['maker'].get('/orders/%s/edit' % oid).status_code == 302
assert clients['maker'].get('/orders/%s/versions/1' % oid).status_code == 200