新增Win7离线构建并调整PDF采购章及供需方签章位置
This commit is contained in:
@@ -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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user