diff --git a/README.md b/README.md index eaa4def..c684691 100644 --- a/README.md +++ b/README.md @@ -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 导入。 diff --git a/output/pdf/委外订单_签章调整样张.pdf b/output/pdf/委外订单_签章调整样张.pdf new file mode 100644 index 0000000..944874b Binary files /dev/null and b/output/pdf/委外订单_签章调整样张.pdf differ diff --git a/output/pdf/采购单_签章调整样张.pdf b/output/pdf/采购单_签章调整样张.pdf new file mode 100644 index 0000000..2731855 Binary files /dev/null and b/output/pdf/采购单_签章调整样张.pdf differ diff --git a/packaging/win7/01_install_env.cmd b/packaging/win7/01_install_env.cmd new file mode 100644 index 0000000..82b81e5 --- /dev/null +++ b/packaging/win7/01_install_env.cmd @@ -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 diff --git a/packaging/win7/02_build_exe.cmd b/packaging/win7/02_build_exe.cmd new file mode 100644 index 0000000..5484b27 --- /dev/null +++ b/packaging/win7/02_build_exe.cmd @@ -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 diff --git a/packaging/win7/README_WIN7.md b/packaging/win7/README_WIN7.md new file mode 100644 index 0000000..ebdd4de --- /dev/null +++ b/packaging/win7/README_WIN7.md @@ -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。 diff --git a/packaging/win7/build.py b/packaging/win7/build.py new file mode 100644 index 0000000..9a32226 --- /dev/null +++ b/packaging/win7/build.py @@ -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) diff --git a/packaging/win7/create_bundle.py b/packaging/win7/create_bundle.py new file mode 100644 index 0000000..6bb7956 --- /dev/null +++ b/packaging/win7/create_bundle.py @@ -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) diff --git a/packaging/win7/make_waitress_wheel.py b/packaging/win7/make_waitress_wheel.py new file mode 100644 index 0000000..8b07ba6 --- /dev/null +++ b/packaging/win7/make_waitress_wheel.py @@ -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)) diff --git a/packaging/win7/probe_exe.py b/packaging/win7/probe_exe.py new file mode 100644 index 0000000..cb17acb --- /dev/null +++ b/packaging/win7/probe_exe.py @@ -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]) diff --git a/packaging/win7/requirements-win7.txt b/packaging/win7/requirements-win7.txt new file mode 100644 index 0000000..aa59352 --- /dev/null +++ b/packaging/win7/requirements-win7.txt @@ -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 diff --git a/packaging/win7/selftest.py b/packaging/win7/selftest.py new file mode 100644 index 0000000..9283b95 --- /dev/null +++ b/packaging/win7/selftest.py @@ -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) diff --git a/po_app/order_routes.py b/po_app/order_routes.py index 0be6627..cf18863 100644 --- a/po_app/order_routes.py +++ b/po_app/order_routes.py @@ -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') diff --git a/po_app/pdf_export.py b/po_app/pdf_export.py index 69abbe6..24d9645 100644 --- a/po_app/pdf_export.py +++ b/po_app/pdf_export.py @@ -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()) diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 6f827a7..ad8014e 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -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