From bf952196fa0fdacfbc90399dcc1a71c9f37efd49 Mon Sep 17 00:00:00 2001 From: reddaniel110 Date: Wed, 16 Sep 2026 11:45:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=87=87=E8=B4=AD=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E6=B5=81=E7=A8=8B=E3=80=81=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E9=80=9A=E7=9F=A5=E5=92=8C=E9=87=87=E8=B4=AD=E4=B8=93?= =?UTF-8?q?=E7=94=A8=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 178 +++----- po_app/amount_cn.py | 11 +- po_app/app.py | 681 ++++------------------------ po_app/excel_io.py | 147 ++++++ po_app/migrations.py | 86 ++++ po_app/models.py | 122 ++++- po_app/money.py | 37 ++ po_app/notification_routes.py | 75 +++ po_app/notifications.py | 213 +++++++++ po_app/order_logic.py | 189 ++++++++ po_app/order_routes.py | 293 ++++++++++++ po_app/pdf_export.py | 200 ++++---- po_app/requirements.txt | 11 +- po_app/security.py | 220 +++++++++ po_app/static/money.js | 22 + po_app/static/style.css | 39 +- po_app/templates/base.html | 10 +- po_app/templates/error.html | 9 + po_app/templates/index.html | 7 +- po_app/templates/login.html | 8 +- po_app/templates/materials.html | 5 + po_app/templates/notifications.html | 57 +++ po_app/templates/order_edit.html | 60 ++- po_app/templates/order_new.html | 2 + po_app/templates/order_version.html | 21 + po_app/templates/order_view.html | 126 +++-- po_app/templates/orders.html | 7 +- po_app/templates/password.html | 17 + po_app/templates/seal_preview.html | 17 + po_app/templates/setup.html | 17 + po_app/templates/stats.html | 2 +- po_app/templates/suppliers.html | 5 + po_app/templates/users.html | 48 +- po_app/使用说明.md | 84 +--- pytest.ini | 2 + requirements-dev.txt | 5 + tests/conftest.py | 64 +++ tests/fixtures/legacy_schema.sql | 109 +++++ tests/test_backup.py | 25 + tests/test_migration.py | 125 +++++ tests/test_money_js.cjs | 7 + tests/test_notifications.py | 238 ++++++++++ tests/test_workflow.py | 339 ++++++++++++++ tools/backup_db.py | 29 ++ tools/browser_smoke.py | 177 ++++++++ tools/e2e_test.py | 100 +--- tools/pdf_preview.py | 4 +- tools/run_check.py | 16 +- tools/run_tests.py | 13 + tools/test_approve_stamp.py | 101 +---- tools/test_batch_import.py | 209 +-------- tools/test_edit_row.py | 120 +---- tools/test_import.py | 74 +-- tools/test_pages.py | 29 +- tools/test_stamp_toggle.py | 157 +------ tools/verify_exe.py | 112 ++--- 运行与升级说明.md | 135 ++++++ 项目检查-20260916.md | 144 ++++++ 58 files changed, 3451 insertions(+), 1909 deletions(-) create mode 100644 po_app/excel_io.py create mode 100644 po_app/migrations.py create mode 100644 po_app/money.py create mode 100644 po_app/notification_routes.py create mode 100644 po_app/notifications.py create mode 100644 po_app/order_logic.py create mode 100644 po_app/order_routes.py create mode 100644 po_app/security.py create mode 100644 po_app/static/money.js create mode 100644 po_app/templates/error.html create mode 100644 po_app/templates/notifications.html create mode 100644 po_app/templates/order_version.html create mode 100644 po_app/templates/password.html create mode 100644 po_app/templates/seal_preview.html create mode 100644 po_app/templates/setup.html create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/legacy_schema.sql create mode 100644 tests/test_backup.py create mode 100644 tests/test_migration.py create mode 100644 tests/test_money_js.cjs create mode 100644 tests/test_notifications.py create mode 100644 tests/test_workflow.py create mode 100644 tools/backup_db.py create mode 100644 tools/browser_smoke.py create mode 100644 tools/run_tests.py create mode 100644 运行与升级说明.md create mode 100644 项目检查-20260916.md diff --git a/README.md b/README.md index 0c99788..f97326a 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,81 @@ # 博特维采购订单管理系统 -> 苏州博特维智能科技有限公司 · 本地采购订单管理系统(采购单 / 委外生产订单) -> -> 制单 → 主管审核 → 经理批准 → 导出 PDF 的全流程本地化工具,按贵司现有 Excel 单据模板套打。 +苏州博特维智能科技有限公司内部使用的采购单 / 委外生产订单系统。当前源码版本 v3,支持制单、单次审批、正式 PDF 归档、撤回、作废及变更重审。 ---- +## 启动 -## 功能特性 - -- **两种单据**:采购单、委外生产订单共用同一套数据,按原模板版式分别套打。 -- **两级审核**:制单员提交 → 主管审核 → 经理批准,每个动作写入审核记录,可追溯、可驳回。 -- **Excel 导入明细**:编辑页上传 `.xls` / `.xlsx`,自动识别表头(代码/规格/单位/数量/单价/交期/备注),行数不限,PDF 自动分页。 -- **供应商库 / 物料库**:制单时下拉带入名称、联系人、税号、单价等信息;支持行内编辑、停用/启用、删除,以及 Excel 批量导入(带模板下载)。 -- **金额大写**:合计自动转人民币大写(壹万贰仟叁佰肆拾伍元整)。 -- **审核盖章**:主管审核前须先「加盖审核章」,服务端二次校验,防止绕过;PDF 渲染红色审核专用章。 -- **历史查询与统计**:按单号 / 供应商 / 状态 / 单据类型 / 日期区间筛选;按状态、供应商、月份汇总金额。 -- **台账导出**:一键导出全部订单为 Excel。 -- **PDF 套打**:完全复刻原 Excel 模板版式,草稿/待审核状态带水印防止误用,已批准状态导出无水印正式版。 - -## 技术栈 - -| 层 | 技术 | -|---|---| -| 后端 | Python 3 + Flask 3 | -| ORM | SQLAlchemy 2 | -| 数据库 | SQLite(单文件 `po.db`,免部署) | -| PDF | reportlab 4(中文套打 + 印章绘制) | -| Excel | openpyxl 3 / xlrd 2 | -| 打包 | PyInstaller(单文件版 / 文件夹版,免 Python 环境) | - -## 目录结构 +需要 Python 3.11+。在项目根目录执行: +```text +python -m pip install -r po_app/requirements.txt +python po_app/app.py 8080 ``` + +打开控制台打印的访问地址,默认 `http://127.0.0.1:8080`。也可双击 `po_app/启动.bat`。 + +首次运行不创建演示账号。管理员须读取服务电脑上的 `po_app/instance/setup.token`,在首次设置页面创建管理员,再通过“用户”页分配制单员、审批员账号。初始/重置密码在首次登录时须更改。 + +局域网协作应由一台固定电脑运行服务,其他电脑用浏览器访问主机 IP。每台电脑单独启动 EXE 会形成不同数据库。已有 EXE 须从本版源码重新打包,不能通过替换源码自动升级 EXE。 + +**已有数据升级前,请阅读 [运行与升级说明](运行与升级说明.md)。** 程序会先备份旧数据库再执行增量迁移;重复单号会阻止升级,不能自动给历史单据改号。 + +## 主要能力 + +- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。 +- 草稿可暂存,“保存并提交”保证当前修改一起进入审批。 +- 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。 +- 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。 +- 制单员仅访问自己的订单,详情、复制、PDF、台账和统计使用统一权限。 +- 撤回、作废、批准后变更重审;每次提交保留内容快照,日志记录操作账号、姓名、环节及版本。 +- 审批通过时归档带一枚“采购专用章”的正式 PDF,后续下载保持该文件;草稿、驳回和作废单带对应水印。 +- 企业微信审批群通知、持久化发送队列、失败重试及过期待办跳过;管理员在“通知配置”页设置。 +- 公司 Wi-Fi 内手机审批,通知链接登录后回到对应订单;保留盖章确认、角色分工和版本校验。 +- 用户编辑、重置密码、启用和停用,以及旧会话失效。 +- 随机部署密钥、表单 CSRF 校验、请求独立事务、单号唯一约束、重复建单和旧页面操作保护。 +- 按状态、供应商、月份统计以及 Excel 台账导出。 + +图形审批章表示系统内部审批确认,不是第三方证书数字签名。金额分级、部门审批、企业微信个人定向通知、预算、到货和付款跟踪不在当前版本中。 + +## 项目结构 + +```text po_app/ -├── app.py # 主程序(路由 / 业务逻辑 / 审核流程) -├── models.py # 数据库模型(用户 / 订单 / 明细 / 供应商 / 物料) -├── pdf_export.py # PDF 套打 + 审核章绘制 -├── amount_cn.py # 金额转人民币大写 -├── requirements.txt # 源码运行依赖 -├── 启动.bat # 双击即启(源码方式) -├── templates/ # Jinja2 模板 -├── static/ # CSS / JS / 资源 -├── 博特维采购订单系统.spec # 文件夹版打包配置 -├── 博特维采购订单系统_单文件版.spec # 单文件版打包配置 -├── 使用说明.md # 详细使用手册 -├── instance/ # 运行时数据库(.gitignore 排除,不入库) -├── exports/ # 导出的 PDF(忽略) -└── uploads/ # 上传的明细文件(忽略) -tools/ # 开发期脚本:测试 / 打包验证 / git 推送 + app.py 服务入口、工作台、基础资料、用户、统计 + security.py 会话、认证、请求事务、首次设置 + order_routes.py 制单及审批操作、PDF 和版本查看 + order_logic.py 权限、校验、金额、提交快照 + notifications.py 企业微信群通知、事务队列与后台发送 + notification_routes.py 管理员通知配置、测试通知和重试 + models.py 数据模型及固定精度字段 + migrations.py 旧 SQLite 数据升级和自动备份 + money.py Decimal 计算与舍入规则 + excel_io.py XLS/XLSX 读取和上传文件名处理 + pdf_export.py PDF 排版、内部审批图形章 + amount_cn.py 人民币大写 + templates/ 页面模板 + static/ 样式、精确金额预览 + instance/ 数据库、部署密钥、备份(不入库) +tests/ 隔离回归测试 +tools/ 备份、测试和浏览器验收脚本 ``` -> 仓库仅含**源码与配置**;数据库、构建产物、运行时数据均经 `.gitignore` 排除,不入库。 +可用 `PO_DATA_DIR` 更改数据目录,`PO_HOST` 更改绑定地址。服务使用 Waitress。历史上传和导出目录同样不入库;新批准的正式 PDF 存在数据库的版本归档中。 -## 快速开始 +企业微信接入及内网手机访问步骤见 [运行与升级说明](运行与升级说明.md#企业微信通知与公司-wi-fi-手机审批)。通知默认关闭,须配置真实 Webhook 和手机可达的内网地址后启用。源码测试使用模拟发送器,不会向真实群发消息。 -三种运行方式任选其一: +## 检查和备份 -1. **单文件 EXE(推荐分发)** - `po_app/dist/博特维采购订单系统_单文件版.exe`,拷贝到任意文件夹双击即用,免 Python 环境。 - -2. **文件夹版 EXE(启动最快)** - 复制 `po_app/dist/博特维采购订单系统/` 整个文件夹,双击其中的 exe 秒开。 - -3. **源码运行** - 安装依赖后双击 `po_app/启动.bat`,或命令行: - - ```bash - cd po_app - pip install -r requirements.txt - python app.py 8080 - ``` - -访问地址 ;局域网其他电脑把 `127.0.0.1` 换成宿主机的局域网 IP(需放行 8080 端口)。 -EXE 版数据落在 exe 同级的 `instance/po.db`,备份即复制该文件夹。 - -## 初始账号 - -| 账号 | 密码 | 角色 | 权限 | -|---|---|---|---| -| admin | admin123 | 系统管理员 | 全部功能,含用户 / 供应商 / 物料维护 | -| maker | 123456 | 制单员 | 新建、编辑、提交订单 | -| reviewer | 123456 | 主管审核 | 审核通过或驳回 | -| approver | 123456 | 经理批准 | 批准后订单生效,可出正式 PDF | - -> ⚠️ 正式使用前请在「用户」页改掉默认密码。 - -## 业务流程 - -``` -制单员新建订单 → 填写抬头与明细 → 提交 - ↓ -主管审核(通过 / 驳回并填原因) - ↓ -经理批准(通过 / 驳回并填原因) - ↓ -状态 = 已批准 → 导出 PDF(无水印,带审核人姓名与时间) +```text +python -m pip install -r requirements-dev.txt +python -m pytest tests -q +node tests/test_money_js.cjs +python tools/browser_smoke.py +python tools/backup_db.py ``` -- 草稿 / 待审核 / 待批准状态导出的 PDF 带斜向水印,避免误用。 -- 被驳回的订单回到制单员名下,可修改后再次提交。 +浏览器验证依赖本机 Microsoft Edge;仅操作临时服务和临时数据库。测试输出在 `tools/out/`,不提交真实业务数据。 -## PDF 套打版式 - -完全复刻原 Excel 模板: - -1. 公司名 + 单据名称(采购单 / 委外生产订单) -2. 抬头区:左供方、右需方(含采购单号、采购日期、税点、税号等) -3. 明细表 9 列:序号 / 物料代码 / 物料规格 / 单位 / 数量 / 含税单价 / 金额 / 交期 / 备注(列宽取自原模板) -4. 合计:小写 + 人民币大写 + 元整 -5. 备注条款 + 交货地点(苏州工业园区星汉街 5 号腾飞新苏工业坊 B 幢 5 楼 05-06 单元) -6. 签署区:制单 / 主管审核 / 经理批准(含姓名与时间)+ 双方盖章栏 -7. 页脚:单据类型、单号、页码、打印时间 - -明细超过一页时自动分页并重复表头,合计与条款跟随在最后一页。 - -## 数据文件与备份 - -| 文件 | 说明 | -|---|---| -| `instance/po.db` | SQLite 主数据库,整个文件夹复制即备份 | -| `exports/` | 导出的 PDF | -| `uploads/` | 上传的明细文件 | - -## 重新打包说明(开发) - -```bash -# 打包前务必先备份 instance/ 与 exports/,PyInstaller 重建文件夹版会清空 dist -python -m PyInstaller "博特维采购订单系统[_单文件版].spec" --noconfirm --clean -``` +备份、恢复、升级限制、完整操作规则及验收步骤见 [运行与升级说明](运行与升级说明.md)。首次检查的历史问题见 [项目检查记录](项目检查-20260916.md),该记录描述修复前版本。 ## 许可 diff --git a/po_app/amount_cn.py b/po_app/amount_cn.py index e08243c..98ee904 100644 --- a/po_app/amount_cn.py +++ b/po_app/amount_cn.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """人民币金额转中文大写。""" +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP def amount_to_cn(amount): @@ -9,15 +10,17 @@ def amount_to_cn(amount): big_units = [u"", u"万", u"亿", u"兆"] try: - amount = round(float(amount) + 0.0, 2) - except (TypeError, ValueError): + amount = Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + if not amount.is_finite(): + raise ValueError('金额无效') + except (InvalidOperation, TypeError, ValueError): return u"零元整" negative = amount < 0 amount = abs(amount) integer_part = int(amount) # 避免浮点误差:分位用字符串处理 - fen = int(round((amount - integer_part) * 100)) + fen = int((amount - integer_part) * 100) if fen == 100: integer_part += 1 fen = 0 @@ -60,7 +63,7 @@ def amount_to_cn(amount): if result and not result.endswith(digits[0]): result += digits[0] continue - if result and idx < len(parts) - 1 and sections[idx] < 1000: + if result and not result.endswith(digits[0]) and idx < len(parts) - 1 and sections[idx] < 1000: result += digits[0] result += p result = result.rstrip(digits[0]) or u"零" diff --git a/po_app/app.py b/po_app/app.py index 3677bd2..36b9eb0 100644 --- a/po_app/app.py +++ b/po_app/app.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""博特维采购订单管理系统:制单 → 主管审核 → 经理批准 → 导出 PDF。""" +"""博特维采购订单管理系统:制单 → 审批 → 导出 PDF。""" import io import os import sys @@ -10,7 +10,12 @@ from flask import (Flask, flash, jsonify, redirect, render_template, request, send_file, send_from_directory, session, url_for) from sqlalchemy import func from werkzeug.security import check_password_hash, generate_password_hash -from werkzeug.utils import secure_filename +from security import db, current_user, login_required, role_required, init_security, validate_password, sign_in +from money import number, format_price +from order_logic import scope +from order_routes import register_order_routes +from excel_io import read_rows, read_material_rows, read_supplier_rows, save_upload +from flask import abort from amount_cn import amount_to_cn from models import (COMPANY_NAME, DOC_TYPES, ROLE_TEXT, STATUS_TEXT, @@ -28,6 +33,7 @@ else: RES_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = RES_DIR +BASE_DIR = os.path.abspath(os.environ.get("PO_DATA_DIR", BASE_DIR)) INSTANCE = os.path.join(BASE_DIR, "instance") EXPORTS = os.path.join(BASE_DIR, "exports") UPLOADS = os.path.join(BASE_DIR, "uploads") @@ -37,194 +43,36 @@ for d in (INSTANCE, EXPORTS, UPLOADS): app = Flask(__name__, template_folder=os.path.join(RES_DIR, "templates"), static_folder=os.path.join(RES_DIR, "static")) -app.secret_key = "botw-po-system-2026" + app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024 engine, Session = init_db(os.path.join(INSTANCE, "po.db").replace("\\", "/")) -db = Session() +app.config['PO_UPLOADS'] = UPLOADS +init_security(app, Session, INSTANCE) +app.jinja_env.filters['price'] = format_price +register_order_routes(app) +from notification_routes import register_notification_routes +register_notification_routes(app) def now(): return datetime.now() -# ---------------- 基础工具 ---------------- -def current_user(): - uid = session.get("user_id") - if not uid: - return None - return db.get(User, uid) - - -def login_required(f): - @wraps(f) - def wrapper(*a, **kw): - if not current_user(): - return redirect(url_for("login")) - return f(*a, **kw) - return wrapper - - -def role_required(*roles): - def deco(f): - @wraps(f) - def wrapper(*a, **kw): - u = current_user() - if not u: - return redirect(url_for("login")) - if u.role != "admin" and u.role not in roles: - flash(u"当前账号无此操作权限", "error") - return redirect(url_for("index")) - return f(*a, **kw) - return wrapper - return deco - - -def add_log(order, action, user, comment=None): - db.add(ApprovalLog(order_id=order.id, action=action, - user_name=user.name if user else u"系统", - comment=comment, created_at=now())) - - -def can_edit(order, user): - if not user: - return False - if user.role == "admin": - return order.status in ("draft", "rejected") - return (order.status in ("draft", "rejected") - and order.created_by == user.id) - - -def next_order_no(doc_type): - """单号规则:PO/WO + 年月日 + 4 位递增数字。 - - 序号解析兼容旧格式(PO20260909-001 与 PO202609090001 都能正确读出), - 这样改规则后历史单据不会导致序号从头开始。 - """ - prefix = "PO" if doc_type == "purchase" else "WO" - head = "%s%s" % (prefix, now().strftime("%Y%m%d")) - seq = 0 - for o in db.query(Order).filter(Order.order_no.like("%s%%" % head)).all(): - tail = (o.order_no[len(head):] or "").lstrip("-") - try: - n = int(tail) - except ValueError: - continue - if n > seq: - seq = n - return "%s%04d" % (head, seq + 1) - - -def recalc(order): - total = 0.0 - for it in order.items: - try: - qty = float(it.qty or 0) - price = float(it.price or 0) - except (TypeError, ValueError): - qty = price = 0.0 - it.amount = round(qty * price, 2) - total += it.amount - order.total_amount = round(total, 2) - return total - - -def parse_items_from_form(form, order): - codes = form.getlist("item_code") - specs = form.getlist("item_spec") - units = form.getlist("item_unit") - qtys = form.getlist("item_qty") - prices = form.getlist("item_price") - dates = form.getlist("item_date") - remarks = form.getlist("item_remark") - order.items = [] - for i in range(len(codes)): - code = (codes[i] or "").strip() - spec = (specs[i] or "").strip() - if not code and not spec: - continue - def f(v): - try: - return float(v) - except (TypeError, ValueError): - return 0.0 - qty = f(qtys[i] if i < len(qtys) else 0) - price = f(prices[i] if i < len(prices) else 0) - order.items.append(OrderItem( - seq=i + 1, material_code=code, material_spec=spec, - unit=(units[i] if i < len(units) else "").strip(), - qty=qty, price=price, amount=round(qty * price, 2), - delivery_date=(dates[i] if i < len(dates) else "").strip(), - remark=(remarks[i] if i < len(remarks) else "").strip())) - recalc(order) - - -# ---------------- 初始化 ---------------- -def seed(): - if db.query(User).count(): - return - users = [ - ("admin", "admin123", u"系统管理员", "admin"), - ("maker", "123456", u"张采购", "maker"), - ("reviewer", "123456", u"李主管", "reviewer"), - ("approver", "123456", u"王经理", "approver"), - ] - for un, pw, name, role in users: - db.add(User(username=un, password_hash=generate_password_hash(pw), - name=name, role=role)) - db.add(Supplier(code="S001", name=u"苏州精锐五金制品有限公司", contact=u"周经理", - phone="13800000001", tax_no="91320500MA1XXXXXXX", - bank_name=u"中国银行苏州工业园区支行", - bank_account="4562 1088 0012 3456", payment_terms=u"月结30天")) - db.add(Supplier(code="S002", name=u"昆山宏远电子科技有限公司", contact=u"陈主管", - phone="13900000002", tax_no="91320583MA2XXXXXXX", - bank_name=u"建设银行昆山分行", - bank_account="3220 1988 2233 4455", payment_terms=u"货到付款")) - db.add(Material(code="M-1001", spec=u"铝合金支架 6061-T6 200×80×10mm", unit=u"件", price=38.5)) - db.add(Material(code="M-1002", spec=u"步进电机 57BYG250 2相", unit=u"台", price=265.0)) - db.add(Material(code="M-1003", spec=u"同步带 5M-500 宽15mm", unit=u"条", price=46.0)) - db.commit() - - -seed() - - -# ---------------- 认证 ---------------- -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - u = db.query(User).filter_by(username=request.form.get("username", "").strip()).first() - if u and u.active and check_password_hash(u.password_hash, request.form.get("password", "")): - session["user_id"] = u.id - session["user_name"] = u.name - session["role"] = u.role - return redirect(url_for("index")) - flash(u"用户名或密码错误", "error") - return render_template("login.html") - - -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("login")) - - # ---------------- 首页 ---------------- @app.route("/") @login_required def index(): u = current_user() todo = [] - if u.role in ("reviewer", "admin"): - todo = db.query(Order).filter_by(status="pending_review").order_by(Order.id.desc()).all() if u.role in ("approver", "admin"): - todo += db.query(Order).filter_by(status="pending_approval").order_by(Order.id.desc()).all() + todo = scope(db.query(Order)).filter(Order.status == 'pending_approval', Order.created_by != u.id).order_by(Order.id.desc()).all() my_orders = [] if u.role in ("maker", "admin"): - my_orders = (db.query(Order).filter_by(created_by=u.id) + my_orders = (scope(db.query(Order)).filter_by(created_by=u.id) .order_by(Order.id.desc()).limit(8).all()) - counts = {s: db.query(Order).filter_by(status=s).count() for s in STATUS_TEXT} - month_total = (db.query(func.sum(Order.total_amount)) + counts = {s: scope(db.query(Order)).filter_by(status=s).count() for s in STATUS_TEXT} + month_total = (scope(db.query(func.sum(Order.total_amount))) .filter(Order.status == "approved").scalar() or 0) return render_template("index.html", user=u, todo=todo, my_orders=my_orders, counts=counts, month_total=month_total, @@ -236,7 +84,7 @@ def index(): @login_required def orders(): u = current_user() - q = db.query(Order) + q = scope(db.query(Order)) kw = request.args.get("kw", "").strip() status = request.args.get("status", "") doc_type = request.args.get("doc_type", "") @@ -260,405 +108,6 @@ def orders(): doc_types=DOC_TYPES, args=request.args) -@app.route("/orders/new", methods=["GET", "POST"]) -@login_required -def order_new(): - u = current_user() - suppliers = db.query(Supplier).filter_by(active=True).all() - if request.method == "POST": - doc_type = request.form.get("doc_type", "purchase") - o = Order(order_no=next_order_no(doc_type), doc_type=doc_type, - status="draft", created_by=u.id, created_by_name=u.name, - created_at=now(), order_date=now().strftime("%Y-%m-%d"), - our_company=COMPANY_NAME, delivery_place=DEFAULT_DELIVERY_PLACE, - our_contact=DEFAULT_OUR_CONTACT, our_phone=DEFAULT_OUR_PHONE, - our_tax_no=DEFAULT_OUR_TAX_NO, tax_rate=DEFAULT_TAX_RATE) - sid = request.form.get("supplier_id") - if sid: - s = db.get(Supplier, int(sid)) - if s: - o.supplier_name = s.name - o.supplier_contact = s.contact - o.supplier_phone = s.phone - o.supplier_tax_no = s.tax_no - o.supplier_bank_name = s.bank_name - o.supplier_bank_account = s.bank_account - o.payment_terms = s.payment_terms - db.add(o) - db.commit() - return redirect(url_for("order_edit", oid=o.id)) - return render_template("order_new.html", user=u, suppliers=suppliers, - doc_types=DOC_TYPES) - - -@app.route("/orders/", methods=["GET", "POST"]) -@app.route("/orders//edit", methods=["GET", "POST"]) -@login_required -def order_edit(oid): - u = current_user() - o = db.get(Order, oid) - if not o: - flash(u"订单不存在", "error") - return redirect(url_for("orders")) - editable = can_edit(o, u) - if request.method == "POST": - if not editable: - flash(u"当前状态不可编辑", "error") - return redirect(url_for("order_view", oid=o.id)) - for f in ("supplier_name", "supplier_contact", "supplier_phone", - "supplier_tax_no", "supplier_bank_name", "supplier_bank_account", - "payment_terms", "our_company", "our_contact", "our_phone", - "our_tax_no", "tax_rate", "order_date", "delivery_place", "note"): - setattr(o, f, request.form.get(f, "").strip()) - parse_items_from_form(request.form, o) - db.commit() - flash(u"已保存", "ok") - return redirect(url_for("order_edit", oid=o.id)) - suppliers = db.query(Supplier).filter_by(active=True).all() - materials = db.query(Material).filter_by(active=True).all() - return render_template("order_edit.html", user=u, o=o, editable=editable, - suppliers=suppliers, materials=materials, - doc_types=DOC_TYPES, total_cn=amount_to_cn(o.total_amount)) - - -@app.route("/orders//view") -@login_required -def order_view(oid): - u = current_user() - o = db.get(Order, oid) - if not o: - flash(u"订单不存在", "error") - return redirect(url_for("orders")) - return render_template("order_view.html", user=u, o=o, editable=can_edit(o, u), - total_cn=amount_to_cn(o.total_amount), - status_text=STATUS_TEXT, role_text=ROLE_TEXT) - - -@app.route("/orders//submit", methods=["POST"]) -@login_required -def order_submit(oid): - u = current_user() - o = db.get(Order, oid) - if not o or not can_edit(o, u): - flash(u"无权限或状态不允许", "error") - return redirect(url_for("orders")) - if not o.items: - flash(u"明细为空,不能提交", "error") - return redirect(url_for("order_edit", oid=o.id)) - o.status = "pending_review" - o.submitted_at = now() - o.reject_reason = None - add_log(o, u"提交审核", u, request.form.get("comment")) - db.commit() - flash(u"已提交,等待主管审核", "ok") - return redirect(url_for("order_view", oid=o.id)) - - -@app.route("/orders//review", methods=["POST"]) -@login_required -@role_required("reviewer") -def order_review(oid): - u = current_user() - o = db.get(Order, oid) - if not o or o.status != "pending_review": - flash(u"状态不允许", "error") - return redirect(url_for("orders")) - # 确认前必须先加盖审核章(服务端校验,防止绕过前端直接提交) - if request.form.get("stamped") != "1": - flash(u"请先加盖审核章,再确认审核", "error") - return redirect(url_for("order_view", oid=o.id)) - o.status = "pending_approval" - o.reviewed_by = u.name - o.reviewed_at = now() - add_log(o, u"主管审核通过(已加盖审核章)", u, request.form.get("comment")) - db.commit() - flash(u"审核通过,等待经理批准", "ok") - return redirect(url_for("order_view", oid=o.id)) - - -@app.route("/orders//approve", methods=["POST"]) -@login_required -@role_required("approver") -def order_approve(oid): - u = current_user() - o = db.get(Order, oid) - if not o or o.status != "pending_approval": - flash(u"状态不允许", "error") - return redirect(url_for("orders")) - # 与主管审核一致:确认前必须先加盖批准章(服务端校验,防绕过) - if request.form.get("stamped") != "1": - flash(u"请先加盖批准章,再确认批准", "error") - return redirect(url_for("order_view", oid=o.id)) - o.status = "approved" - o.approved_by = u.name - o.approved_at = now() - add_log(o, u"经理批准(已加盖批准章)", u, request.form.get("comment")) - db.commit() - flash(u"已批准,可以导出 PDF", "ok") - return redirect(url_for("order_view", oid=o.id)) - - -@app.route("/orders//reject", methods=["POST"]) -@login_required -def order_reject(oid): - u = current_user() - o = db.get(Order, oid) - if not o: - flash(u"订单不存在", "error") - return redirect(url_for("orders")) - if u.role not in ("admin", "reviewer", "approver"): - flash(u"无权限", "error") - return redirect(url_for("order_view", oid=o.id)) - if o.status not in ("pending_review", "pending_approval"): - flash(u"状态不允许驳回", "error") - return redirect(url_for("order_view", oid=o.id)) - reason = request.form.get("comment", "").strip() - if not reason: - flash(u"请填写驳回原因", "error") - return redirect(url_for("order_view", oid=o.id)) - from_status = o.status - o.status = "rejected" - o.reject_reason = reason - o.reviewed_by = None - o.reviewed_at = None - o.approved_by = None - o.approved_at = None - add_log(o, u"驳回(%s)" % (u"审核环节" if from_status == "pending_review" else u"批准环节"), - u, reason) - db.commit() - flash(u"已驳回给制单人", "ok") - return redirect(url_for("order_view", oid=o.id)) - - -@app.route("/orders//copy", methods=["POST"]) -@login_required -def order_copy(oid): - u = current_user() - src = db.get(Order, oid) - if not src: - flash(u"订单不存在", "error") - return redirect(url_for("orders")) - o = Order(order_no=next_order_no(src.doc_type), doc_type=src.doc_type, - status="draft", created_by=u.id, created_by_name=u.name, - created_at=now(), order_date=now().strftime("%Y-%m-%d")) - for f in ("supplier_name", "supplier_contact", "supplier_phone", "supplier_tax_no", - "supplier_bank_name", "supplier_bank_account", "payment_terms", - "our_company", "our_contact", "our_phone", "our_tax_no", "tax_rate", - "delivery_place", "note"): - setattr(o, f, getattr(src, f)) - # 老单据可能没存需方信息,复制时补默认值,避免新单缺项 - if not o.our_contact: - o.our_contact = DEFAULT_OUR_CONTACT - if not o.our_phone: - o.our_phone = DEFAULT_OUR_PHONE - if not o.our_tax_no: - o.our_tax_no = DEFAULT_OUR_TAX_NO - if not o.tax_rate: - o.tax_rate = DEFAULT_TAX_RATE - db.add(o) - db.flush() - for it in src.items: - db.add(OrderItem(order_id=o.id, seq=it.seq, material_code=it.material_code, - material_spec=it.material_spec, unit=it.unit, qty=it.qty, - price=it.price, amount=it.amount, - delivery_date=it.delivery_date, remark=it.remark)) - recalc(o) - db.commit() - flash(u"已复制为新订单 %s" % o.order_no, "ok") - return redirect(url_for("order_edit", oid=o.id)) - - -@app.route("/orders//pdf") -@login_required -def order_pdf(oid): - o = db.get(Order, oid) - if not o: - flash(u"订单不存在", "error") - return redirect(url_for("orders")) - fname = "%s_%s.pdf" % (o.order_no, now().strftime("%H%M%S")) - path = os.path.join(EXPORTS, fname) - build_pdf(o, o.items, path) - dl = request.args.get("dl") == "1" - return send_file(path, as_attachment=dl, - download_name="%s %s.pdf" % (o.doc_type_text, o.order_no)) - - -@app.route("/orders//import_excel", methods=["POST"]) -@login_required -def import_excel(oid): - u = current_user() - o = db.get(Order, oid) - if not o or not can_edit(o, u): - flash(u"无权限或状态不允许", "error") - return redirect(url_for("orders")) - file = request.files.get("file") - if not file or not file.filename: - flash(u"请选择文件", "error") - return redirect(url_for("order_edit", oid=o.id)) - fname = secure_filename(file.filename) - path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"), fname)) - file.save(path) - try: - rows = read_rows(path) - except Exception as e: - flash(u"读取失败:%s" % e, "error") - return redirect(url_for("order_edit", oid=o.id)) - mode = request.form.get("mode", "append") - if mode == "replace": - o.items = [] - seq = len(o.items) - added = 0 - for r in rows: - if not any(r): - continue - code, spec, unit, qty, price, date, remark = (list(r) + [""] * 7)[:7] - def f(v): - try: - return float(str(v).strip()) - except (TypeError, ValueError): - return 0.0 - seq += 1 - q = f(qty) - p = f(price) - o.items.append(OrderItem(seq=seq, material_code=str(code or "").strip(), - material_spec=str(spec or "").strip(), - unit=str(unit or "").strip(), qty=q, price=p, - amount=round(q * p, 2), - delivery_date=str(date or "").strip(), - remark=str(remark or "").strip())) - added += 1 - recalc(o) - db.commit() - flash(u"已导入 %d 行明细" % added, "ok") - return redirect(url_for("order_edit", oid=o.id)) - - -def read_rows(path): - """读取 xlsx/xls 明细,返回 [代码,规格,单位,数量,单价,交期,备注]。""" - ext = os.path.splitext(path)[1].lower() - grid = [] - if ext in (".xlsx", ".xlsm"): - import openpyxl - wb = openpyxl.load_workbook(path, data_only=True) - for row in wb[wb.sheetnames[0]].iter_rows(values_only=True): - grid.append(["" if c is None else c for c in row]) - else: - import xlrd - bk = xlrd.open_workbook(path) - sh = bk.sheet_by_index(0) - for r in range(sh.nrows): - grid.append([sh.cell_value(r, c) for c in range(sh.ncols)]) - keys = {"code": [u"代码", u"料号", u"物料编码", u"品号"], - "spec": [u"规格", u"名称", u"品名", u"描述"], - "unit": [u"单位"], - "qty": [u"数量", u"数量(QTY)"], - "price": [u"单价", u"含税单价", u"价格"], - "date": [u"交期", u"交货日期", u"日期"], - "remark": [u"备注"]} - order = ["code", "spec", "unit", "qty", "price", "date", "remark"] - header_idx = None - for i, row in enumerate(grid[:8]): - text = " ".join(str(c) for c in row) - if any(k in text for k in keys["code"]) and any(k in text for k in keys["qty"]): - header_idx = i - break - if header_idx is None: - return [[c for c in row[:7]] for row in grid] - heads = [str(c or "").strip() for c in grid[header_idx]] - mapping = {} - for idx, h in enumerate(heads): - for k, ks in keys.items(): - if any(x in h for x in ks): - mapping[k] = idx - break - out = [] - for row in grid[header_idx + 1:]: - vals = [] - for k in order: - i = mapping.get(k) - vals.append(row[i] if (i is not None and i < len(row)) else "") - out.append(vals) - return out - - -def read_table(path, keys, order): - """通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。 - - 与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可), - 因为物料表 / 供应商表都没有"数量"列,套用 read_rows 会退化成按位置读。 - """ - ext = os.path.splitext(path)[1].lower() - grid = [] - if ext in (".xlsx", ".xlsm"): - import openpyxl - wb = openpyxl.load_workbook(path, data_only=True) - for row in wb[wb.sheetnames[0]].iter_rows(values_only=True): - grid.append(["" if c is None else c for c in row]) - else: - import xlrd - bk = xlrd.open_workbook(path) - sh = bk.sheet_by_index(0) - for r in range(sh.nrows): - grid.append([sh.cell_value(r, c) for c in range(sh.ncols)]) - header_idx = None - for i, row in enumerate(grid[:8]): - text = " ".join(str(c) for c in row) - if any(any(k in text for k in ks) for ks in keys.values()): - header_idx = i - break - if header_idx is None: - return [list(row[:len(order)]) for row in grid] - heads = [str(c or "").strip() for c in grid[header_idx]] - mapping = {} - for idx, h in enumerate(heads): - for k, ks in keys.items(): - if k in mapping: - continue - if any(x in h for x in ks): - mapping[k] = idx - break - out = [] - for row in grid[header_idx + 1:]: - vals = [] - for k in order: - i = mapping.get(k) - vals.append(row[i] if (i is not None and i < len(row)) else "") - out.append(vals) - return out - - -MATERIAL_KEYS = { - "code": [u"物料代码", u"料号", u"物料编码", u"品号", u"代码"], - "spec": [u"规格", u"名称", u"品名", u"描述"], - "unit": [u"单位"], - "price": [u"单价", u"价格"], -} -MATERIAL_ORDER = ["code", "spec", "unit", "price"] - -SUPPLIER_KEYS = { - "code": [u"编号", u"供应商编号", u"代码"], - "name": [u"名称", u"供应商名称", u"公司"], - "contact": [u"联系人"], - "phone": [u"电话", u"手机", u"联系方式"], - "payment_terms": [u"付款方式", u"结算方式", u"账期"], - "tax_no": [u"税号", u"纳税人识别号"], - "bank_name": [u"开户银行", u"银行名称"], - "bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"], -} -SUPPLIER_ORDER = ["code", "name", "contact", "phone", "payment_terms", - "tax_no", "bank_name", "bank_account"] - - -def read_material_rows(path): - """读取物料表,返回 [代码, 规格, 单位, 单价]。""" - return read_table(path, MATERIAL_KEYS, MATERIAL_ORDER) - - -def read_supplier_rows(path): - """读取供应商表,返回 [编号,名称,联系人,电话,付款方式,税号,开户银行,银行帐号]。""" - return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER) - - @app.route("/import_template.xlsx") @login_required def import_template(): @@ -685,9 +134,13 @@ def suppliers(): if request.method == "POST": sid = request.form.get("id") s = db.get(Supplier, int(sid)) if sid else Supplier() + if s is None: + abort(404) for f in ("code", "name", "contact", "phone", "tax_no", "bank_name", "bank_account", "payment_terms"): setattr(s, f, request.form.get(f, "").strip()) + if not s.name: + raise ValueError('供应商名称必填') if not sid: db.add(s) db.commit() @@ -732,9 +185,7 @@ def supplier_import_excel(): if not file or not file.filename: flash(u"请选择文件", "error") return redirect(url_for("suppliers")) - path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"), - secure_filename(file.filename))) - file.save(path) + path = save_upload(file) try: rows = read_supplier_rows(path) except Exception as e: @@ -818,13 +269,14 @@ def materials(): if request.method == "POST": mid = request.form.get("id") m = db.get(Material, int(mid)) if mid else Material() + if m is None: + abort(404) m.code = request.form.get("code", "").strip() + if not m.code: + raise ValueError('物料代码必填') m.spec = request.form.get("spec", "").strip() m.unit = request.form.get("unit", "").strip() - try: - m.price = float(request.form.get("price") or 0) - except ValueError: - m.price = 0 + m.price = number(request.form.get('price'), '默认含税单价') if not mid: db.add(m) db.commit() @@ -869,9 +321,7 @@ def material_import_excel(): if not file or not file.filename: flash(u"请选择文件", "error") return redirect(url_for("materials")) - path = os.path.join(UPLOADS, "%s_%s" % (now().strftime("%H%M%S"), - secure_filename(file.filename))) - file.save(path) + path = save_upload(file) try: rows = read_material_rows(path) except Exception as e: @@ -885,10 +335,7 @@ def material_import_excel(): return str(v or "").strip() def num(v): - try: - return float(str(v or "").strip() or 0) - except (TypeError, ValueError): - return 0.0 + return number(v, '导入单价') for i, r in enumerate(rows, start=2): if not any(r): @@ -967,22 +414,41 @@ def users(): u = current_user() if request.method == "POST": uid = request.form.get("id") - t = db.get(User, int(uid)) if uid else User() - t.username = request.form.get("username", "").strip() - t.name = request.form.get("name", "").strip() - t.role = request.form.get("role", "maker") - pw = request.form.get("password", "").strip() + t = db.get(User, int(uid)) if uid else User(session_version=1) + if t is None: + abort(404) + username = request.form.get('username', '').strip() + name = request.form.get('name', '').strip() + role = request.form.get('role', 'maker') + active = request.form.get('active', '1') == '1' + if not username or len(username) > 50 or not name or len(name) > 50: + raise ValueError('登录名和姓名必填,且不超过 50 字') + if role not in ROLE_TEXT: + raise ValueError('角色无效') + if uid and t.role == 'admin' and t.active and (role != 'admin' or not active): + if db.query(User).filter_by(role='admin', active=True).count() <= 1: + raise ValueError('必须保留至少一个启用的管理员') + t.username, t.name, t.role, t.active = username, name, role, active + pw = request.form.get('password', '') if pw: + validate_password(pw) t.password_hash = generate_password_hash(pw) + t.must_change_password = True elif not uid: - t.password_hash = generate_password_hash("123456") + raise ValueError('请为新账号设置初始密码') + if uid: + t.session_version += 1 if not uid: db.add(t) db.commit() - flash(u"已保存", "ok") + if t.id == u.id: + sign_in(t) + flash(u"已保存,原有登录已失效", "ok") return redirect(url_for("users")) rows = db.query(User).order_by(User.id).all() - return render_template("users.html", user=u, rows=rows, roles=ROLE_TEXT) + edit_id = request.args.get('edit', type=int) + target = db.get(User, edit_id) if edit_id else None + return render_template("users.html", user=u, rows=rows, roles=ROLE_TEXT, target=target) # ---------------- 统计 ---------------- @@ -990,15 +456,15 @@ def users(): @login_required def stats(): u = current_user() - by_status = (db.query(Order.status, func.count(Order.id), func.sum(Order.total_amount)) + by_status = (scope(db.query(Order.status, func.count(Order.id), func.sum(Order.total_amount))) .group_by(Order.status).all()) - by_supplier = (db.query(Order.supplier_name, func.count(Order.id), - func.sum(Order.total_amount)) + by_supplier = (scope(db.query(Order.supplier_name, func.count(Order.id), + func.sum(Order.total_amount))) .filter(Order.status == "approved") .group_by(Order.supplier_name) .order_by(func.sum(Order.total_amount).desc()).limit(20).all()) - by_month = (db.query(func.substr(Order.order_date, 1, 7), func.count(Order.id), - func.sum(Order.total_amount)) + by_month = (scope(db.query(func.substr(Order.order_date, 1, 7), func.count(Order.id), + func.sum(Order.total_amount))) .group_by(func.substr(Order.order_date, 1, 7)).all()) return render_template("stats.html", user=u, by_status=by_status, by_supplier=by_supplier, by_month=by_month, @@ -1009,17 +475,17 @@ def stats(): @login_required def export_ledger(): import openpyxl - rows = db.query(Order).order_by(Order.id.desc()).all() + rows = scope(db.query(Order)).order_by(Order.id.desc()).all() wb = openpyxl.Workbook() ws = wb.active ws.append([u"采购单号", u"单据类型", u"供应商", u"采购日期", u"状态", u"金额", - u"制单人", u"审核人", u"批准人", u"创建时间"]) + u"制单人", u"审批人", u"创建时间"]) for o in rows: ws.append([o.order_no, o.doc_type_text, o.supplier_name, o.order_date, o.status_text, o.total_amount, o.created_by_name, - o.reviewed_by, o.approved_by, + o.approved_by, o.created_at.strftime("%Y-%m-%d %H:%M") if o.created_at else ""]) - for col, w in zip("ABCDEFGHIJ", (18, 14, 30, 12, 12, 12, 12, 12, 12, 18)): + for col, w in zip("ABCDEFGHI", (18, 14, 30, 12, 12, 12, 12, 12, 18)): ws.column_dimensions[col].width = w buf = io.BytesIO() wb.save(buf) @@ -1032,7 +498,8 @@ def export_ledger(): @app.route("/exports/") @login_required def exports_file(name): - return send_from_directory(EXPORTS, name) + # Legacy filenames are not an authorization boundary. Use the order PDF endpoint. + abort(404) @app.context_processor @@ -1068,4 +535,10 @@ if __name__ == "__main__": print(" 本机访问: http://127.0.0.1:%d" % port) print(" 关闭本窗口即停止服务") print("=" * 56) - app.run(host="0.0.0.0", port=port, debug=False, threaded=True) + from waitress import serve + from notifications import start_notification_worker + notification_stop = start_notification_worker(app, Session) + try: + serve(app, host=os.environ.get('PO_HOST', '0.0.0.0'), port=port, threads=8) + finally: + notification_stop.set() diff --git a/po_app/excel_io.py b/po_app/excel_io.py new file mode 100644 index 0000000..34a94c2 --- /dev/null +++ b/po_app/excel_io.py @@ -0,0 +1,147 @@ +import os +import uuid +from pathlib import Path +from flask import current_app + + +def save_upload(file): + if not file or not file.filename: + raise ValueError('请选择 Excel 文件') + extension = Path(file.filename).suffix.lower() + if extension not in ('.xls', '.xlsx'): + raise ValueError('仅支持 .xls 和 .xlsx 文件') + path = os.path.join(current_app.config['PO_UPLOADS'], uuid.uuid4().hex + extension) + file.save(path) + return path + + +def read_rows(path): + """读取 xlsx/xls 明细,返回 [代码,规格,单位,数量,单价,交期,备注]。""" + ext = os.path.splitext(path)[1].lower() + grid = [] + if ext in (".xlsx", ".xlsm"): + import openpyxl + wb = openpyxl.load_workbook(path, data_only=True, read_only=True) + for row in wb[wb.sheetnames[0]].iter_rows(values_only=True): + grid.append(["" if c is None else c for c in row]) + wb.close() + else: + import xlrd + bk = xlrd.open_workbook(path) + sh = bk.sheet_by_index(0) + for r in range(sh.nrows): + grid.append([xlrd.xldate_as_datetime(sh.cell_value(r, c), bk.datemode) + if sh.cell_type(r, c) == xlrd.XL_CELL_DATE else sh.cell_value(r, c) + for c in range(sh.ncols)]) + keys = {"code": [u"代码", u"料号", u"物料编码", u"品号"], + "spec": [u"规格", u"名称", u"品名", u"描述"], + "unit": [u"单位"], + "qty": [u"数量", u"数量(QTY)"], + "price": [u"单价", u"含税单价", u"价格"], + "date": [u"交期", u"交货日期", u"日期"], + "remark": [u"备注"]} + order = ["code", "spec", "unit", "qty", "price", "date", "remark"] + header_idx = None + for i, row in enumerate(grid[:8]): + text = " ".join(str(c) for c in row) + if any(k in text for k in keys["code"]) and any(k in text for k in keys["qty"]): + header_idx = i + break + if header_idx is None: + return [[c for c in row[:7]] for row in grid] + heads = [str(c or "").strip() for c in grid[header_idx]] + mapping = {} + for idx, h in enumerate(heads): + for k, ks in keys.items(): + if any(x in h for x in ks): + mapping[k] = idx + break + out = [] + for row in grid[header_idx + 1:]: + vals = [] + for k in order: + i = mapping.get(k) + vals.append(row[i] if (i is not None and i < len(row)) else "") + out.append(vals) + return out + + +def read_table(path, keys, order): + """通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。 + + 与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可), + 因为物料表 / 供应商表都没有"数量"列,套用 read_rows 会退化成按位置读。 + """ + ext = os.path.splitext(path)[1].lower() + grid = [] + if ext in (".xlsx", ".xlsm"): + import openpyxl + wb = openpyxl.load_workbook(path, data_only=True, read_only=True) + for row in wb[wb.sheetnames[0]].iter_rows(values_only=True): + grid.append(["" if c is None else c for c in row]) + wb.close() + else: + import xlrd + bk = xlrd.open_workbook(path) + sh = bk.sheet_by_index(0) + for r in range(sh.nrows): + grid.append([xlrd.xldate_as_datetime(sh.cell_value(r, c), bk.datemode) + if sh.cell_type(r, c) == xlrd.XL_CELL_DATE else sh.cell_value(r, c) + for c in range(sh.ncols)]) + header_idx = None + for i, row in enumerate(grid[:8]): + text = " ".join(str(c) for c in row) + if any(any(k in text for k in ks) for ks in keys.values()): + header_idx = i + break + if header_idx is None: + return [list(row[:len(order)]) for row in grid] + heads = [str(c or "").strip() for c in grid[header_idx]] + mapping = {} + for idx, h in enumerate(heads): + for k, ks in keys.items(): + if k in mapping: + continue + if any(x in h for x in ks): + mapping[k] = idx + break + out = [] + for row in grid[header_idx + 1:]: + vals = [] + for k in order: + i = mapping.get(k) + vals.append(row[i] if (i is not None and i < len(row)) else "") + out.append(vals) + return out + + +MATERIAL_KEYS = { + "code": [u"物料代码", u"料号", u"物料编码", u"品号", u"代码"], + "spec": [u"规格", u"名称", u"品名", u"描述"], + "unit": [u"单位"], + "price": [u"单价", u"价格"], +} +MATERIAL_ORDER = ["code", "spec", "unit", "price"] + +SUPPLIER_KEYS = { + "code": [u"编号", u"供应商编号", u"代码"], + "name": [u"名称", u"供应商名称", u"公司"], + "contact": [u"联系人"], + "phone": [u"电话", u"手机", u"联系方式"], + "payment_terms": [u"付款方式", u"结算方式", u"账期"], + "tax_no": [u"税号", u"纳税人识别号"], + "bank_name": [u"开户银行", u"银行名称"], + "bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"], +} +SUPPLIER_ORDER = ["code", "name", "contact", "phone", "payment_terms", + "tax_no", "bank_name", "bank_account"] + + +def read_material_rows(path): + """读取物料表,返回 [代码, 规格, 单位, 单价]。""" + return read_table(path, MATERIAL_KEYS, MATERIAL_ORDER) + + +def read_supplier_rows(path): + """读取供应商表,返回 [编号,名称,联系人,电话,付款方式,税号,开户银行,银行帐号]。""" + return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER) diff --git a/po_app/migrations.py b/po_app/migrations.py new file mode 100644 index 0000000..565fef3 --- /dev/null +++ b/po_app/migrations.py @@ -0,0 +1,86 @@ +"""Additive legacy SQLite migration. Never rewrites existing order numbers.""" +import sqlite3 +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path + + +def upgrade_database(path): + path = Path(path) + if not path.exists(): + return + with sqlite3.connect(path) as con: + tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if 'orders' not in tables: + return + version = con.execute('PRAGMA user_version').fetchone()[0] + if version >= 3: + return + duplicates = con.execute('SELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) > 1').fetchall() + if duplicates: + raise RuntimeError('升级前请处理重复单号(原数据未修改):%s' % duplicates) + backup = path.with_name(path.name + '.pre-upgrade-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.bak') + with sqlite3.connect(backup) as dest: + con.backup(dest) + con.execute('BEGIN IMMEDIATE') + try: + if version < 2: + upgrade_fixed_amounts(con) + merge_approval(con, tables) + con.execute('PRAGMA user_version=3') + con.commit() + except Exception: + con.rollback() + raise + + +def upgrade_fixed_amounts(con): + additions = { + 'users': {'session_version': 'INTEGER NOT NULL DEFAULT 1', 'must_change_password': 'BOOLEAN NOT NULL DEFAULT 1'}, + 'orders': {'reviewed_by_id': 'INTEGER', 'approved_by_id': 'INTEGER', 'revision': 'INTEGER NOT NULL DEFAULT 0', 'row_version': 'INTEGER NOT NULL DEFAULT 1'}, + 'approval_logs': {'user_id': 'INTEGER', 'revision': 'INTEGER NOT NULL DEFAULT 0', 'from_status': 'VARCHAR(20)', 'to_status': 'VARCHAR(20)'}, + } + for table, columns in additions.items(): + existing = {r[1] for r in con.execute('PRAGMA table_info(%s)' % table)} + for name, definition in columns.items(): + if name not in existing: + con.execute('ALTER TABLE %s ADD COLUMN %s %s' % (table, name, definition)) + for table, old, new, places in [ + ('materials', 'price', 'price_units', 4), ('orders', 'total_amount', 'total_cents', 2), + ('order_items', 'qty', 'qty_units', 4), ('order_items', 'price', 'price_units', 4), + ('order_items', 'amount', 'amount_cents', 2), + ]: + columns = {r[1] for r in con.execute('PRAGMA table_info(%s)' % table)} + if new in columns: + continue + con.execute('ALTER TABLE %s ADD COLUMN %s INTEGER DEFAULT 0' % (table, new)) + for row_id, value in con.execute('SELECT id, %s FROM %s' % (old, table)).fetchall(): + val = Decimal(str(value or 0)) + if not val.is_finite() or abs(val) * 10 ** places > 2 ** 63 - 1: + raise RuntimeError('无法迁移 %s.id=%s 的金额/数量,请检查原始数据' % (table, row_id)) + scaled = int((val * 10 ** places).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + con.execute('UPDATE %s SET %s=? WHERE id=?' % (table, new), (scaled, row_id)) + con.execute('CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_no ON orders(order_no)') + + +def merge_approval(con, tables): + # Check queued tasks against the OLD status before consolidating stages. + # Completed deliveries/logs and archived PDF bytes must remain untouched. + if 'notifications' in tables: + con.execute("""UPDATE notifications SET status='cancelled', + last_error='审批流程已合并,旧通知已失效', lease_token=NULL, lease_until=NULL + WHERE channel IN ('review','approval') AND status IN ('pending','retry','sending','failed') + AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.id=notifications.order_id + AND o.status=notifications.expected_status AND o.revision=notifications.revision + AND o.status IN ('pending_review','pending_approval'))""") + con.execute("""UPDATE notifications SET channel='approval', expected_status='pending_approval', + event='待审批(流程已合并)' WHERE channel IN ('review','approval') + AND status IN ('pending','retry','sending','failed')""") + if 'notification_settings' in tables: + con.execute("""UPDATE notification_settings SET approval_webhook= + CASE WHEN COALESCE(approval_webhook,'')='' THEN COALESCE(review_webhook,'') + ELSE approval_webhook END, review_webhook=''""") + con.execute("""UPDATE orders SET status='pending_approval', row_version=row_version+1 + WHERE status IN ('pending_review','pending_approval')""") + con.execute("""UPDATE users SET role='approver', session_version=session_version+1 + WHERE role='reviewer'""") diff --git a/po_app/models.py b/po_app/models.py index 397566c..ffab1c4 100644 --- a/po_app/models.py +++ b/po_app/models.py @@ -1,13 +1,35 @@ # -*- coding: utf-8 -*- """数据模型:用户、供应商、物料、订单、明细、审核记录。""" from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path -from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, - String, Text, create_engine) +from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, + String, Text, LargeBinary, UniqueConstraint, create_engine, event) +from sqlalchemy.types import TypeDecorator from sqlalchemy.orm import declarative_base, relationship, sessionmaker Base = declarative_base() + +class FixedDecimal(TypeDecorator): + """SQLite stores integers; Python always receives Decimal, including SUM.""" + impl = Integer + cache_ok = True + + def __init__(self, places=2): + self.places = places + self.factor = 10 ** places + super().__init__() + + def process_bind_param(self, value, dialect): + if value is None: + return None + return int((Decimal(str(value)) * self.factor).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + def process_result_value(self, value, dialect): + return Decimal(value) / self.factor if value is not None else None + COMPANY_NAME = u"苏州博特维智能科技有限公司" DEFAULT_DELIVERY_PLACE = (u"苏州市工业园区星汉街5号腾飞新苏工业坊B幢5楼05-06单元 " u"胡乃琛 13913172516") @@ -24,17 +46,16 @@ DOC_TYPES = { STATUS_TEXT = { "draft": u"草稿", - "pending_review": u"待主管审核", - "pending_approval": u"待经理批准", - "approved": u"已批准", + "pending_approval": u"待审批", + "approved": u"审批通过", "rejected": u"已驳回", + "voided": u"已作废", } ROLE_TEXT = { "admin": u"系统管理员", "maker": u"制单员", - "reviewer": u"主管审核", - "approver": u"经理批准", + "approver": u"审批员", } @@ -47,6 +68,8 @@ class User(Base): role = Column(String(20), nullable=False, default="maker") active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.now) + session_version = Column(Integer, nullable=False, default=1) + must_change_password = Column(Boolean, nullable=False, default=True) class Supplier(Base): @@ -69,14 +92,14 @@ class Material(Base): code = Column(String(80), nullable=False) spec = Column(String(300)) unit = Column(String(20)) - price = Column(Float, default=0) + price = Column('price_units', FixedDecimal(4), default=0) active = Column(Boolean, default=True) class Order(Base): __tablename__ = "orders" id = Column(Integer, primary_key=True) - order_no = Column(String(50), nullable=False) + order_no = Column(String(50), nullable=False, unique=True) doc_type = Column(String(20), nullable=False, default="purchase") supplier_name = Column(String(200)) @@ -96,7 +119,7 @@ class Order(Base): delivery_place = Column(String(300), default=DEFAULT_DELIVERY_PLACE) note = Column(Text) - total_amount = Column(Float, default=0) + total_amount = Column('total_cents', FixedDecimal(), default=0) status = Column(String(20), default="draft") created_by = Column(Integer, ForeignKey("users.id")) @@ -108,11 +131,17 @@ class Order(Base): approved_by = Column(String(50)) approved_at = Column(DateTime) reject_reason = Column(Text) + reviewed_by_id = Column(Integer, ForeignKey('users.id')) + approved_by_id = Column(Integer, ForeignKey('users.id')) + revision = Column(Integer, nullable=False, default=0) + row_version = Column(Integer, nullable=False, default=1) + __mapper_args__ = {'version_id_col': row_version} items = relationship("OrderItem", backref="order", cascade="all, delete-orphan", order_by="OrderItem.seq") logs = relationship("ApprovalLog", backref="order", cascade="all, delete-orphan", order_by="ApprovalLog.id") + versions = relationship('OrderVersion', backref='order', order_by='OrderVersion.revision') @property def status_text(self): @@ -131,9 +160,9 @@ class OrderItem(Base): material_code = Column(String(80)) material_spec = Column(String(300)) unit = Column(String(20)) - qty = Column(Float, default=0) - price = Column(Float, default=0) - amount = Column(Float, default=0) + qty = Column('qty_units', FixedDecimal(4), default=0) + price = Column('price_units', FixedDecimal(4), default=0) + amount = Column('amount_cents', FixedDecimal(), default=0) delivery_date = Column(String(30)) remark = Column(String(200)) @@ -146,9 +175,74 @@ class ApprovalLog(Base): user_name = Column(String(50)) comment = Column(Text) created_at = Column(DateTime, default=datetime.now) + user_id = Column(Integer, ForeignKey('users.id')) + revision = Column(Integer, nullable=False, default=0) + from_status = Column(String(20)) + to_status = Column(String(20)) + + +class OrderVersion(Base): + __tablename__ = 'order_versions' + __table_args__ = (UniqueConstraint('order_id', 'revision'),) + id = Column(Integer, primary_key=True) + order_id = Column(Integer, ForeignKey('orders.id'), nullable=False) + revision = Column(Integer, nullable=False) + content_json = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.now) + pdf_data = Column(LargeBinary) + pdf_sha256 = Column(String(64)) + template_version = Column(String(30)) + + +class CreationRequest(Base): + __tablename__ = 'creation_requests' + key = Column(String(64), primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + operation = Column(String(80), nullable=False) + order_id = Column(Integer, ForeignKey('orders.id'), nullable=False) + + +class NotificationSettings(Base): + __tablename__ = 'notification_settings' + id = Column(Integer, primary_key=True) + enabled = Column(Boolean, nullable=False, default=False) + base_url = Column(String(300), nullable=False, default='') + review_webhook = Column(Text, nullable=False, default='') + approval_webhook = Column(Text, nullable=False, default='') + result_webhook = Column(Text, nullable=False, default='') + next_dispatch_at = Column(DateTime) + + +class Notification(Base): + __tablename__ = 'notifications' + id = Column(Integer, primary_key=True) + log_id = Column(Integer, ForeignKey('approval_logs.id'), unique=True) + order_id = Column(Integer, ForeignKey('orders.id')) + revision = Column(Integer, nullable=False, default=0) + channel = Column(String(20), nullable=False) + event = Column(String(50), nullable=False) + expected_status = Column(String(20)) + status = Column(String(20), nullable=False, default='pending', index=True) + attempts = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + next_attempt_at = Column(DateTime, nullable=False, default=datetime.utcnow) + sent_at = Column(DateTime) + lease_until = Column(DateTime) + lease_token = Column(String(32)) + last_error = Column(String(200), nullable=False, default='') def init_db(db_path): - engine = create_engine("sqlite:///" + db_path, echo=False) + from migrations import upgrade_database + existed = Path(db_path).exists() + upgrade_database(db_path) + engine = create_engine("sqlite:///" + db_path, echo=False, hide_parameters=True, connect_args={'timeout': 30}) + @event.listens_for(engine, 'connect') + def sqlite_settings(connection, _): + connection.execute('PRAGMA foreign_keys=ON') + connection.execute('PRAGMA busy_timeout=30000') Base.metadata.create_all(engine) + if not existed: + with engine.connect() as con: + con.exec_driver_sql('PRAGMA user_version=3') return engine, sessionmaker(bind=engine) diff --git a/po_app/money.py b/po_app/money.py new file mode 100644 index 0000000..49299a7 --- /dev/null +++ b/po_app/money.py @@ -0,0 +1,37 @@ +"""Exact fixed point arithmetic shared by persistence, forms and PDF output.""" +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +ZERO = Decimal('0') +CENT = Decimal('0.01') +UNIT = Decimal('0.0001') +MAX_VALUE = Decimal('999999999') +MAX_TOTAL = Decimal('999999999999.99') + + +def number(value, label='数值', allow_zero=True): + try: + result = Decimal(str(value if value not in (None, '') else 0).strip()) + except (InvalidOperation, ValueError): + raise ValueError('%s必须是有效数字' % label) + if not result.is_finite() or result < 0 or result > MAX_VALUE: + raise ValueError('%s必须在 0 到 %s 之间' % (label, MAX_VALUE)) + if result != result.quantize(UNIT): + raise ValueError('%s最多保留四位小数' % label) + if not allow_zero and result == 0: + raise ValueError('%s必须大于零' % label) + return result.quantize(UNIT) + + +def money(value): + return Decimal(str(value or 0)).quantize(CENT, rounding=ROUND_HALF_UP) + + +def line_amount(qty, price): + amount = money(number(qty, '数量') * number(price, '单价')) + if amount > MAX_TOTAL: + raise ValueError('明细金额过大') + return amount + + +def format_price(value): + return format(Decimal(str(value or 0)), '.4f').rstrip('0').rstrip('.') or '0' diff --git a/po_app/notification_routes.py b/po_app/notification_routes.py new file mode 100644 index 0000000..6c66156 --- /dev/null +++ b/po_app/notification_routes.py @@ -0,0 +1,75 @@ +"""Administrator-only notification setup and delivery diagnostics.""" +from datetime import datetime + +from flask import abort, flash, redirect, render_template, request, url_for + +from models import Notification, NotificationSettings +from notifications import CHANNELS, STATES, display_time, validate_base_url, validate_webhook +from security import current_user, db, role_required + + +def register_notification_routes(app): + app.jinja_env.filters['notice_time'] = display_time + + @app.route('/settings/notifications', methods=['GET', 'POST']) + @role_required('admin') + def notification_settings(): + config = db.get(NotificationSettings, 1) + if request.method == 'POST': + if not config: + config = NotificationSettings(id=1) + db.add(config) + config.base_url = validate_base_url(request.form.get('base_url', '')) + for channel in CHANNELS: + field = channel + '_webhook' + value = request.form.get(field, '').strip() + if request.form.get('clear_' + channel) == '1': + setattr(config, field, '') + elif value: + setattr(config, field, validate_webhook(value)) + elif not getattr(config, field): + setattr(config, field, '') + config.enabled = request.form.get('enabled') == '1' + if config.enabled and not (config.base_url and config.approval_webhook): + raise ValueError('启用通知前,请配置内网访问地址和审批通知群') + db.commit() + flash('通知配置已保存;启用后新发生的审批操作会自动通知', 'ok') + return redirect(url_for('notification_settings')) + # Never pass credentials to a template, even through hidden inputs. + settings = {'enabled': bool(config and config.enabled), 'base_url': config.base_url if config else '', + 'configured': {c: bool(config and getattr(config, c + '_webhook')) for c in CHANNELS}} + rows = db.query(Notification).order_by(Notification.id.desc()).limit(100).all() + response = app.make_response(render_template('notifications.html', user=current_user(), + settings=settings, notices=rows, channels=CHANNELS, states=STATES)) + response.headers['Cache-Control'] = 'no-store' + return response + + @app.post('/settings/notifications/test') + @role_required('admin') + def notification_test(): + config = db.get(NotificationSettings, 1) + channel = request.form.get('channel', '') + if channel not in CHANNELS or not config or not config.enabled or not getattr(config, channel + '_webhook'): + raise ValueError('请先启用并保存该群的通知配置') + # Double taps/reloads should not create a burst of test messages. + if not db.query(Notification).filter(Notification.order_id.is_(None), Notification.channel == channel, + Notification.status.in_(('pending', 'retry', 'sending'))).first(): + db.add(Notification(channel=channel, event='测试通知')) + db.commit() + flash('测试通知已加入队列,请稍后刷新查看结果并在群内确认', 'ok') + return redirect(url_for('notification_settings')) + + @app.post('/settings/notifications//retry') + @role_required('admin') + def notification_retry(nid): + notice = db.get(Notification, nid) + if not notice: + abort(404) + if notice.status not in ('failed', 'retry'): + abort(409, description='该通知无需重试,请刷新查看最新状态') + notice.status, notice.attempts, notice.last_error = 'pending', 0, '' + notice.next_attempt_at = datetime.utcnow() + notice.lease_token = notice.lease_until = None + db.commit() + flash('已安排重试;发送前会再次核对订单状态', 'ok') + return redirect(url_for('notification_settings')) diff --git a/po_app/notifications.py b/po_app/notifications.py new file mode 100644 index 0000000..358971f --- /dev/null +++ b/po_app/notifications.py @@ -0,0 +1,213 @@ +"""Transactional WeCom outbox. Network calls never hold an order transaction. + +Protocol: https://developer.work.weixin.qq.com/document/path/91770 +The worker uses its own sessions; importing the app never sends a message. +""" +import ipaddress +import json +import re +import secrets +import threading +from datetime import datetime, timedelta, timezone +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from sqlalchemy import and_, or_, text + +from models import Notification, NotificationSettings, Order + +CHANNELS = {'approval': '审批通知群', 'result': '结果通知群'} +STATES = {'pending': '等待发送', 'sending': '发送中', 'retry': '等待重试', + 'sent': '已发送', 'failed': '发送失败', 'cancelled': '已跳过'} +MAX_ATTEMPTS = 5 + + +def validate_webhook(value): + value = value.strip() + if value and not re.fullmatch( + r'https://qyapi\.weixin\.qq\.com/cgi-bin/webhook/send\?key=[A-Za-z0-9_-]{1,200}', value): + raise ValueError('Webhook 须为企业微信群机器人的完整 HTTPS 地址') + return value + + +def validate_base_url(value): + value = value.strip().rstrip('/') + if not value: + return '' + try: + parsed = urlsplit(value) + host = parsed.hostname or '' + port = parsed.port + if (parsed.scheme not in ('http', 'https') or not host or parsed.username or parsed.password + or parsed.path or parsed.query or parsed.fragment or len(value) > 300 + or re.search(r'[\s\\<>"()\[\]]', value.replace('[' + host + ']', host)) + or port == 0): + raise ValueError() + try: + addr = ipaddress.ip_address(host) + except ValueError: + if (host.lower() == 'localhost' or host.lower().endswith('.localhost') + or not re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?', host) + or re.fullmatch(r'[0-9.]+', host)): + raise ValueError() + else: + networks = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7') + if not any(addr in ipaddress.ip_network(net) for net in networks): + raise ValueError() + except ValueError: + raise ValueError('请填写手机可访问的内网根地址,例如 http://192.168.1.10:8080;不要使用本机回环地址或订单路径') from None + return value + + +def enqueue_transition(db, order, log): + config = db.get(NotificationSettings, 1) + if not config or not config.enabled: + return + channel = 'approval' if order.status == 'pending_approval' else 'result' + if not getattr(config, channel + '_webhook'): + return + db.add(Notification(log_id=log.id, order_id=order.id, revision=order.revision, + channel=channel, event=log.action, expected_status=order.status)) + + +def display_time(value): + return value.replace(tzinfo=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M') + + +def payload_for(notice, order, base_url): + if order: + # Legacy order numbers are untrusted Markdown input. No prices, bank data, + # comments, supplier contacts or account tokens are sent to the group. + number = re.sub(r'[^\w\-]', ' ', order.order_no)[:50] + event = re.sub(r'[^\w\-]', ' ', notice.event)[:50] + content = ('**采购订单通知**\n订单:%s\n第 %s 次提交 · %s\n' + '记录时间:%s\n[打开订单](%s/orders/%s/view)\n' + '请连接公司 Wi-Fi 后登录;以订单页最新状态为准。') % ( + number, notice.revision, event, display_time(notice.created_at), + base_url, order.id) + else: + content = '**采购订单测试通知**\n%s\n[打开工作台](%s/)\n请连接公司 Wi-Fi 后登录。' % ( + CHANNELS[notice.channel], base_url) + return {'msgtype': 'markdown', 'markdown': {'content': content}} + + +class DeliveryError(Exception): + """Only controlled, credential-free messages may cross this boundary.""" + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def send_wecom(webhook, payload): + validate_webhook(webhook) + if not webhook: + raise DeliveryError('未配置该通知群') + data = json.dumps(payload, ensure_ascii=False).encode('utf-8') + req = Request(webhook, data=data, headers={'Content-Type': 'application/json; charset=utf-8'}, method='POST') + try: + with build_opener(NoRedirect()).open(req, timeout=5) as response: + raw = response.read(16385) + if response.status != 200 or len(raw) > 16384: + raise DeliveryError('企业微信响应异常') + result = json.loads(raw) + code = result.get('errcode') if isinstance(result, dict) else None + if type(code) is not int: + raise DeliveryError('企业微信响应格式错误') + if code != 0: + raise DeliveryError('企业微信拒绝消息,错误码 %s' % code) + except HTTPError as error: + raise DeliveryError('企业微信 HTTP 错误 %s' % error.code) from None + except (URLError, OSError, TimeoutError): + raise DeliveryError('无法连接企业微信,请检查网络后重试') from None + except (ValueError, UnicodeError): + raise DeliveryError('企业微信响应格式错误') from None + + +def dispatch_once(factory, sender=None, now=None): + """Claim -> commit -> send -> acknowledge, with a recoverable lease. + + At-least-once delivery: a timeout/crash after WeCom accepts a message can + cause a duplicate notice. Approvals still require authenticated POSTs. + """ + now = now or datetime.utcnow() + with factory() as db: + db.execute(text('BEGIN IMMEDIATE')) + config = db.get(NotificationSettings, 1) + if not config or not config.enabled: + return False + if config.next_dispatch_at and config.next_dispatch_at > now: + return False + notice = db.query(Notification).filter(or_( + and_(Notification.status.in_(('pending', 'retry')), Notification.next_attempt_at <= now), + and_(Notification.status == 'sending', Notification.lease_until <= now) + )).order_by(Notification.id).first() + if not notice: + return False + order = db.get(Order, notice.order_id) if notice.order_id else None + obsolete = notice.order_id and (not order or (notice.channel in ('review', 'approval') and ( + order.revision != notice.revision or order.status != notice.expected_status))) + webhook = getattr(config, notice.channel + '_webhook') + if obsolete or not webhook: + notice.status = 'cancelled' + notice.last_error = '订单已进入其他状态或版本' if obsolete else '通知群配置已移除' + db.commit() + return True + if notice.attempts >= MAX_ATTEMPTS: + notice.status, notice.last_error = 'failed', '已达到最大尝试次数,请检查后手动重试' + db.commit() + return True + notice.attempts += 1 + notice.status = 'sending' + notice.lease_token = secrets.token_hex(16) + notice.lease_until = now + timedelta(seconds=60) + # Shared across workers and channels, including when two channels use + # the same bot. Up to 15 attempts/minute from this installation. + config.next_dispatch_at = now + timedelta(seconds=4) + payload = payload_for(notice, order, config.base_url) + notice_id, lease = notice.id, notice.lease_token + db.commit() + + error = '' + try: + (sender or send_wecom)(webhook, payload) + except DeliveryError as failure: + error = str(failure) + except Exception: + # Exceptions may embed the secret URL. Never persist/log their text. + error = '通知发送异常,请检查网络及配置' + with factory() as db: + db.execute(text('BEGIN IMMEDIATE')) + notice = db.get(Notification, notice_id) + if notice.status != 'sending' or notice.lease_token != lease: + return True + notice.lease_until = notice.lease_token = None + notice.last_error = error + if error: + notice.status = 'failed' if notice.attempts >= MAX_ATTEMPTS else 'retry' + notice.next_attempt_at = now + timedelta(seconds=min(900, 30 * 2 ** (notice.attempts - 1))) + else: + notice.status, notice.sent_at = 'sent', now + db.commit() + return True + + +def start_notification_worker(app, factory): + """Call once from the server entry point; returns a stop event for shutdown.""" + if 'po_notification_stop' in app.extensions: + return app.extensions['po_notification_stop'] + stop = threading.Event() + app.extensions['po_notification_stop'] = stop + + def work(): + while not stop.is_set(): + try: + dispatch_once(factory) + except Exception: + app.logger.error('Notification worker iteration failed; will retry (details suppressed)') + stop.wait(1) + + threading.Thread(target=work, name='wecom-outbox', daemon=True).start() + return stop diff --git a/po_app/order_logic.py b/po_app/order_logic.py new file mode 100644 index 0000000..52a2e36 --- /dev/null +++ b/po_app/order_logic.py @@ -0,0 +1,189 @@ +import json +import re +from datetime import date, datetime +from decimal import Decimal + +from flask import abort, request +from models import ApprovalLog, CreationRequest, DOC_TYPES, Order, OrderItem, OrderVersion +from money import MAX_TOTAL, ZERO, line_amount, money, number +from security import current_user, db + +ORDER_FIELDS = ('supplier_name', 'supplier_contact', 'supplier_phone', 'supplier_tax_no', + 'supplier_bank_name', 'supplier_bank_account', 'payment_terms', 'our_company', + 'our_contact', 'our_phone', 'our_tax_no', 'tax_rate', 'order_date', 'delivery_place', 'note') +ITEM_FIELDS = ('material_code', 'material_spec', 'unit', 'qty', 'price', 'amount', 'delivery_date', 'remark') + + +def scope(query, user=None): + user = user or current_user() + if user.role == 'maker': + return query.filter(Order.created_by == user.id) + if user.role not in ('admin', 'approver'): + abort(403) + return query + + +def get_order(oid, mutation=False): + order = scope(db.query(Order)).filter(Order.id == oid).first() + if order is None: + abort(404) + if mutation and request.form.get('row_version') != str(order.row_version): + abort(409, description='订单已经更新或页面已过期,请刷新后核对再操作') + return order + + +def can_edit(order, user): + return bool(user and user.role in ('maker', 'admin') and order.status in ('draft', 'rejected') + and (user.role == 'admin' or order.created_by == user.id)) + + +def can_approve(order, user): + return (user.role in ('approver', 'admin') and order.status == 'pending_approval' + and order.created_by != user.id) + + +def can_reject(order, user): + return can_approve(order, user) + + +def next_order_no(doc_type): + if doc_type not in DOC_TYPES: + raise ValueError('单据类型无效') + head = ('PO' if doc_type == 'purchase' else 'WO') + datetime.now().strftime('%Y%m%d') + seq = 0 + for (value,) in db.query(Order.order_no).filter(Order.order_no.like(head + '%')): + tail = value[len(head):].lstrip('-') + if tail.isdigit(): + seq = max(seq, int(tail)) + return '%s%04d' % (head, seq + 1) + + +def creation_request(operation): + key = request.form.get('request_key', '') + if not re.fullmatch(r'[a-f0-9]{32}', key): + raise ValueError('新建页面已过期,请重新打开') + existing = db.get(CreationRequest, key) + if existing and (existing.user_id != current_user().id or existing.operation != operation): + abort(409) + return key, existing + + +def remember_creation(key, operation, order): + db.flush() + db.add(CreationRequest(key=key, user_id=current_user().id, operation=operation, order_id=order.id)) + + +def normalize_date(value, label='日期', required=False): + if value in (None, ''): + if required: + raise ValueError('请填写%s' % label) + return '' + if isinstance(value, (datetime, date)): + return value.strftime('%Y-%m-%d') + value = str(value).strip() + try: + return datetime.strptime(value, '%Y-%m-%d').strftime('%Y-%m-%d') + except ValueError: + raise ValueError('%s应使用 YYYY-MM-DD 格式' % label) + + +def recalc(order): + total = ZERO + for item in order.items: + item.amount = line_amount(item.qty, item.price) + total += item.amount + if total > MAX_TOTAL: + raise ValueError('订单合计金额过大') + order.total_amount = money(total) + + +def make_item(values, seq): + code, spec, unit, qty, price, delivery, remark = values + limits = [(code, 80, '物料代码'), (spec, 300, '物料规格'), (unit, 20, '单位'), (remark, 200, '备注')] + for value, limit, label in limits: + if len(str(value or '')) > limit: + raise ValueError('第 %s 行%s超过 %s 字' % (seq, label, limit)) + return OrderItem(seq=seq, material_code=str(code or '').strip(), material_spec=str(spec or '').strip(), + unit=str(unit or '').strip(), qty=number(qty, '第 %s 行数量' % seq), + price=number(price, '第 %s 行单价' % seq), delivery_date=normalize_date(delivery, '第 %s 行交期' % seq), + remark=str(remark or '').strip()) + + +def save_form(order, form): + for field in ORDER_FIELDS: + value = form.get(field, '').strip() + limit = getattr(Order, field).property.columns[0].type.length + if len(value) > (limit or 10000): + raise ValueError('单据字段内容过长:%s' % field) + setattr(order, field, normalize_date(value, '采购日期') if field == 'order_date' else value) + columns = [form.getlist('item_' + key) for key in ('code', 'spec', 'unit', 'qty', 'price', 'date', 'remark')] + count = max(map(len, columns), default=0) + if count > 2000: + raise ValueError('单张订单最多 2000 行明细') + items = [] + for i in range(count): + values = [col[i] if i < len(col) else '' for col in columns] + if not any(str(x or '').strip() for x in values[:2]): + if any(str(x or '').strip() not in ('', '0', '0.0', '0.0000') for x in values[2:]): + raise ValueError('第 %s 行请填写物料代码或规格' % (i + 1)) + continue + items.append(make_item(values, len(items) + 1)) + order.items = items + recalc(order) + # Item-only changes must invalidate stale approval/edit forms as well. + order.row_version += 1 + + +def validate_order(order): + for field, label in [('supplier_name', '供应商'), ('our_company', '需方名称'), + ('payment_terms', '付款方式'), ('delivery_place', '交货地点')]: + if not (getattr(order, field) or '').strip(): + raise ValueError('提交前请填写%s' % label) + normalize_date(order.order_date, '采购日期', required=True) + if not order.items: + raise ValueError('明细为空,不能提交') + for i, item in enumerate(order.items, 1): + if not (item.material_code or item.material_spec) or not item.unit: + raise ValueError('第 %s 行请填写物料及单位' % i) + number(item.qty, '第 %s 行数量' % i, allow_zero=False) + number(item.price, '第 %s 行单价' % i, allow_zero=False) + normalize_date(item.delivery_date, '第 %s 行交期' % i, required=True) + recalc(order) + + +def clear_approval(order): + order.reviewed_by = order.reviewed_at = order.reviewed_by_id = None + order.approved_by = order.approved_at = order.approved_by_id = None + + +def snapshot(order): + content = {field: getattr(order, field) for field in ORDER_FIELDS} + content.update(order_no=order.order_no, doc_type=order.doc_type, total_amount=str(order.total_amount), + created_by=order.created_by, created_by_name=order.created_by_name, + items=[{field: str(getattr(item, field)) if isinstance(getattr(item, field), Decimal) + else getattr(item, field) for field in ITEM_FIELDS} for item in order.items]) + return json.dumps(content, ensure_ascii=False, sort_keys=True) + + +def add_log(order, action, user, comment=None, from_status=None): + db.flush() + log = ApprovalLog(order_id=order.id, action=action, user_id=user.id, user_name=user.name, + revision=order.revision, comment=(comment or '')[:2000], + from_status=from_status, to_status=order.status) + db.add(log) + if from_status and from_status != order.status: + from notifications import enqueue_transition + db.flush() + enqueue_transition(db, order, log) + + +def submit(order, user, comment): + validate_order(order) + old = order.status + clear_approval(order) + order.revision += 1 + order.status = 'pending_approval' + order.submitted_at = datetime.now() + order.reject_reason = None + db.add(OrderVersion(order_id=order.id, revision=order.revision, content_json=snapshot(order))) + add_log(order, '提交审批', user, comment, old) diff --git a/po_app/order_routes.py b/po_app/order_routes.py new file mode 100644 index 0000000..cae0794 --- /dev/null +++ b/po_app/order_routes.py @@ -0,0 +1,293 @@ +import hashlib +import io +import json +from datetime import datetime + +from flask import abort, flash, redirect, render_template, request, send_file, url_for +from models import (COMPANY_NAME, DEFAULT_DELIVERY_PLACE, DEFAULT_OUR_CONTACT, DEFAULT_OUR_PHONE, + DEFAULT_OUR_TAX_NO, DEFAULT_TAX_RATE, DOC_TYPES, ROLE_TEXT, STATUS_TEXT, + Material, Order, OrderItem, OrderVersion, Supplier) +from amount_cn import amount_to_cn +from excel_io import read_rows, save_upload +from order_logic import (ORDER_FIELDS, ITEM_FIELDS, add_log, can_edit, can_approve, + 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 security import current_user, db, login_required, role_required + + +def pdf_bytes(order): + buf = io.BytesIO() + build_pdf(order, order.items, buf) + return buf.getvalue() + + +def register_order_routes(app): + @app.route('/orders/new', methods=['GET', 'POST']) + @login_required + @role_required('maker') + def order_new(): + user = current_user() + if request.method == 'POST': + key, existing = creation_request('new') + if existing: + return redirect(url_for('order_edit', oid=existing.order_id)) + doc_type = request.form.get('doc_type', 'purchase') + order = Order(order_no=next_order_no(doc_type), doc_type=doc_type, status='draft', + created_by=user.id, created_by_name=user.name, order_date=datetime.now().strftime('%Y-%m-%d'), + our_company=COMPANY_NAME, delivery_place=DEFAULT_DELIVERY_PLACE, + our_contact=DEFAULT_OUR_CONTACT, our_phone=DEFAULT_OUR_PHONE, + our_tax_no=DEFAULT_OUR_TAX_NO, tax_rate=DEFAULT_TAX_RATE) + sid = request.form.get('supplier_id') + if sid: + supplier = db.get(Supplier, int(sid)) + if not supplier or not supplier.active: + raise ValueError('请选择有效供应商') + for target, source in [('supplier_name', 'name'), ('supplier_contact', 'contact'), + ('supplier_phone', 'phone'), ('supplier_tax_no', 'tax_no'), + ('supplier_bank_name', 'bank_name'), ('supplier_bank_account', 'bank_account'), + ('payment_terms', 'payment_terms')]: + setattr(order, target, getattr(supplier, source)) + db.add(order) + remember_creation(key, 'new', order) + add_log(order, '创建草稿', user) + db.commit() + return redirect(url_for('order_edit', oid=order.id)) + return render_template('order_new.html', user=user, suppliers=db.query(Supplier).filter_by(active=True).all(), doc_types=DOC_TYPES) + + @app.route('/orders/', methods=['GET', 'POST']) + @app.route('/orders//edit', methods=['GET', 'POST']) + @login_required + def order_edit(oid): + user = current_user() + order = get_order(oid, mutation=request.method == 'POST') + if not can_edit(order, user): + if request.method == 'POST': + abort(403) + return redirect(url_for('order_view', oid=oid)) + if request.method == 'POST': + save_form(order, request.form) + if request.form.get('intent') == 'submit': + submit(order, user, request.form.get('comment')) + message = '已保存并提交审批' + else: + add_log(order, '保存草稿', user) + message = '已保存' + db.commit() + flash(message, 'ok') + return redirect(url_for('order_view' if order.status == 'pending_approval' else 'order_edit', oid=oid)) + return render_template('order_edit.html', user=user, o=order, editable=True, + suppliers=db.query(Supplier).filter_by(active=True).all(), + materials=db.query(Material).filter_by(active=True).all(), doc_types=DOC_TYPES, + total_cn=amount_to_cn(order.total_amount)) + + @app.route('/orders//view') + @login_required + def order_view(oid): + user, order = current_user(), get_order(oid) + return render_template('order_view.html', user=user, o=order, editable=can_edit(order, user), + may_approve=can_approve(order, user), + seal=seal_layout(order.our_company) if can_approve(order, user) else None, + may_reject=can_reject(order, user), total_cn=amount_to_cn(order.total_amount), + status_text=STATUS_TEXT, role_text=ROLE_TEXT) + + @app.route('/orders//submit', methods=['POST']) + @login_required + def order_submit(oid): + user, order = current_user(), get_order(oid, mutation=True) + if not can_edit(order, user): + abort(403) + submit(order, user, request.form.get('comment')) + db.commit() + flash('已提交审批', 'ok') + return redirect(url_for('order_view', oid=oid)) + + @app.route('/orders//review', methods=['POST']) + @login_required + def order_review(oid): + # Never turn a stale first-stage confirmation into a final approval. + get_order(oid) + abort(409, description='审核和批准已合并,请重新打开订单,核对后进行审批') + + @app.route('/orders//approve', methods=['POST']) + @login_required + def order_approve(oid): + user, order = current_user(), get_order(oid, mutation=True) + if not can_approve(order, user): + abort(403) + if request.form.get('stamped') != '1': + raise ValueError('请先加盖采购专用章,再确认审批') + validate_order(order) + old = order.status + if order.revision == 0: + order.revision = 1 + db.add(OrderVersion(order_id=oid, revision=1, content_json=snapshot(order), template_version='legacy-current')) + order.status = 'approved' + order.approved_by, order.approved_by_id, order.approved_at = user.name, user.id, datetime.now() + db.flush() + 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' + add_log(order, '审批通过并归档正式 PDF', user, request.form.get('comment'), old) + db.commit() + flash('审批通过,正式 PDF 已归档', 'ok') + return redirect(url_for('order_view', oid=oid)) + + @app.route('/orders//reject', methods=['POST']) + @login_required + def order_reject(oid): + user, order = current_user(), get_order(oid, mutation=True) + if not can_reject(order, user): + abort(403) + reason = request.form.get('comment', '').strip() + if not reason: + raise ValueError('请填写驳回原因') + old = order.status + order.status, order.reject_reason = 'rejected', reason + clear_approval(order) + add_log(order, '审批驳回', user, reason, old) + db.commit() + return redirect(url_for('order_view', oid=oid)) + + @app.route('/orders//withdraw', methods=['POST']) + @login_required + def order_withdraw(oid): + user, order = current_user(), get_order(oid, mutation=True) + if order.status != 'pending_approval' or order.created_by != user.id: + abort(403) + reason = request.form.get('comment', '').strip() + if not reason: + raise ValueError('请填写撤回原因') + old = order.status + order.status = 'draft' + clear_approval(order) + add_log(order, '制单人撤回', user, reason, old) + db.commit() + return redirect(url_for('order_edit', oid=oid)) + + @app.route('/orders//void', methods=['POST']) + @login_required + def order_void(oid): + user, order = current_user(), get_order(oid, mutation=True) + allowed = can_edit(order, user) or (order.status == 'approved' and user.role in ('admin', 'approver')) + if not allowed: + abort(403) + reason = request.form.get('comment', '').strip() + if not reason: + raise ValueError('请填写作废原因') + old = order.status + order.status = 'voided' + add_log(order, '作废订单', user, reason, old) + db.commit() + return redirect(url_for('order_view', oid=oid)) + + @app.route('/orders//revise', methods=['POST']) + @login_required + def order_revise(oid): + user, order = current_user(), get_order(oid, mutation=True) + if order.status != 'approved' or user.role not in ('maker', 'admin') or (user.role != 'admin' and order.created_by != user.id): + abort(403) + reason = request.form.get('comment', '').strip() + if not reason: + raise ValueError('请填写变更原因') + if order.revision == 0: + # Preserve available legacy content without pretending it is the original submitted version. + order.revision = 1 + data = pdf_bytes(order) + db.add(OrderVersion(order_id=oid, revision=1, content_json=snapshot(order), pdf_data=data, + pdf_sha256=hashlib.sha256(data).hexdigest(), template_version='legacy-current')) + order.status = 'draft' + clear_approval(order) + add_log(order, '发起变更,原批准停止使用', user, reason, 'approved') + db.commit() + flash('已进入变更草稿,须重新审批;原审批文件请停止使用', 'ok') + return redirect(url_for('order_edit', oid=oid)) + + @app.route('/orders//versions/') + @login_required + def order_version(oid, revision): + order = get_order(oid) + version = db.query(OrderVersion).filter_by(order_id=oid, revision=revision).first() + if not version: + abort(404) + return render_template('order_version.html', user=current_user(), o=order, version=version, + content=json.loads(version.content_json)) + + @app.route('/orders//copy', methods=['POST']) + @login_required + @role_required('maker') + def order_copy(oid): + user, source = current_user(), get_order(oid) + key, existing = creation_request('copy:%s' % oid) + if existing: + return redirect(url_for('order_edit', oid=existing.order_id)) + if request.form.get('row_version') != str(source.row_version): + abort(409, description='源订单已更新,请刷新后重试') + order = Order(order_no=next_order_no(source.doc_type), doc_type=source.doc_type, + status='draft', created_by=user.id, created_by_name=user.name) + for field in ORDER_FIELDS: + setattr(order, field, getattr(source, field)) + order.order_date = datetime.now().strftime('%Y-%m-%d') + order.items = [OrderItem(seq=i, **{field: getattr(item, field) for field in ITEM_FIELDS}) + for i, item in enumerate(source.items, 1)] + recalc(order) + db.add(order) + remember_creation(key, 'copy:%s' % oid, order) + add_log(order, '复制创建草稿', user, '来源:' + source.order_no) + db.commit() + return redirect(url_for('order_edit', oid=order.id)) + + @app.route('/orders//pdf') + @login_required + def order_pdf(oid): + order = get_order(oid) + version = db.query(OrderVersion).filter_by(order_id=oid, revision=order.revision).first() + if order.status == 'approved' and version and version.pdf_data: + data = version.pdf_data + if hashlib.sha256(data).hexdigest() != version.pdf_sha256: + abort(500, description='归档文件校验失败,请联系管理员') + else: + data = pdf_bytes(order) + response = send_file(io.BytesIO(data), mimetype='application/pdf', as_attachment=request.args.get('dl') == '1', + download_name='%s %s.pdf' % (order.doc_type_text, order.order_no)) + response.headers['Cache-Control'] = 'private, no-store' + return response + + @app.route('/orders//import_excel', methods=['POST']) + @login_required + def import_excel(oid): + user, order = current_user(), get_order(oid, mutation=True) + if not can_edit(order, user): + abort(403) + path = save_upload(request.files.get('file')) + try: + rows = read_rows(path) + except Exception as exc: + raise ValueError('无法读取 Excel,请检查文件格式及表头') from exc + items = [] + for row in rows: + if not any(x not in ('', None) for x in row): + continue + values = (list(row) + [''] * 7)[:7] + if not (values[0] or values[1]): + raise ValueError('导入明细缺少物料代码或规格') + items.append(make_item(values, len(items) + 1)) + if not items: + raise ValueError('文件中没有有效明细,原明细已保留') + mode = request.form.get('mode', 'append') + if mode not in ('append', 'replace'): + raise ValueError('导入方式无效') + combined = items if mode == 'replace' else list(order.items) + items + if len(combined) > 2000: + raise ValueError('单张订单最多 2000 行明细') + for seq, item in enumerate(combined, 1): + item.seq = seq + order.items = combined + recalc(order) + order.row_version += 1 + add_log(order, '导入明细', user, '%s %s 行' % (mode, len(items))) + db.commit() + flash('已导入 %s 行明细' % len(items), 'ok') + return redirect(url_for('order_edit', oid=oid)) diff --git a/po_app/pdf_export.py b/po_app/pdf_export.py index a00efd0..69abbe6 100644 --- a/po_app/pdf_export.py +++ b/po_app/pdf_export.py @@ -2,6 +2,7 @@ """按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。""" import math import os +import io from datetime import datetime from xml.sax.saxutils import escape @@ -17,19 +18,24 @@ from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, KeepTogether, Table, TableStyle) from amount_cn import amount_to_cn +from money import ZERO, format_price, money from models import COMPANY_NAME, DOC_TYPES FONT_DIRS = [r"C:\Windows\Fonts", "/usr/share/fonts"] BODY_CANDIDATES = ["simsun.ttc", "STSONG.TTF", "simfang.ttf", "simkai.ttf", "Deng.ttf"] BOLD_CANDIDATES = ["simhei.ttf", "msyh.ttc", "msyhbd.ttc", "Dengb.ttf", "STXIHEI.TTF"] +SEAL_CANDIDATES = ["simfang.ttf", "STFANGSO.TTF", "simsun.ttc", "STSONG.TTF"] +SEAL_NUMBER_CANDIDATES = ["arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf"] FONT_BODY = "CN-Body" FONT_BOLD = "CN-Bold" +FONT_SEAL = "CN-Seal" +FONT_SEAL_NUMBER = "Seal-Number" -# 明细列宽比例,取自两个 xls 模板的实际列宽 +# 延续两种模板比例;委外单将过宽的单位列让给规格列。 COL_RATIO = { "purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728], - "outsourcing": [1184, 5440, 3392, 5408, 2400, 2976, 3840, 3712, 4736], + "outsourcing": [1184, 5440, 7264, 1536, 2400, 2976, 3840, 3712, 4736], } TERMS = [ @@ -45,84 +51,105 @@ TERMS = [ STATUS_WATERMARK = { "draft": u"草稿 · 未提交", - "pending_review": u"待主管审核", - "pending_approval": u"待经理批准", + "pending_review": u"待审批", + "pending_approval": u"待审批", "rejected": u"已驳回 · 未生效", + "voided": u"已作废 · 禁止使用", } SEAL_COLOR = colors.HexColor("#C8102E") +SEAL_NUMBER = "3205070994140" -def _arc_text(c, text, cx, cy, radius, start_deg, spread_deg, font, size): - """沿圆弧排字,用于印章上下缘。角度以度为单位,逆时针为正。""" - if not text: - return - c.setFont(font, size) - n = len(text) - step = spread_deg / float(n - 1) if n > 1 else 0.0 - for i, ch in enumerate(text): - ang = start_deg - i * step - rad = math.radians(ang) - c.saveState() - c.translate(cx + radius * math.cos(rad), cy + radius * math.sin(rad)) - c.rotate(ang - 90) - c.drawCentredString(0, 0, ch) - c.restoreState() +def _seal_arc(text, radius, start, span, font, size, bottom=False, + scale_x=1, scale_y=1): + """Lay out both renderers in a 100 x 100 Cartesian coordinate system. + + Fit by glyph width, so seal-number characters do not get the same + angular spacing as Chinese characters. The lower arc remains upright. + """ + length = radius * math.radians(span) + width = pdfmetrics.stringWidth(text, font, size) * scale_x + size *= min(1, length * 0.94 / width) if width else 1 + widths = [pdfmetrics.stringWidth(ch, font, size) * scale_x for ch in text] + gap = (length - sum(widths)) / max(len(text), 1) + cursor = gap / 2 + letters = [] + for ch, width in zip(text, widths): + offset = math.degrees((cursor + width / 2) / radius) + angle = start + offset if bottom else start - offset + rad = math.radians(angle) + letters.append({'text': ch, 'x': 50 + radius * math.cos(rad), + 'y': 50 + radius * math.sin(rad), + 'rotation': angle + 90 if bottom else angle - 90}) + cursor += width + gap + return {'font': font, 'size': size, 'letters': letters, + 'scale_x': scale_x, 'scale_y': scale_y} -def _star(c, cx, cy, r_out, points=5, inner_ratio=0.382): - """画五角星(填充)。""" - path = c.beginPath() - for i in range(points * 2): - r = r_out if i % 2 == 0 else r_out * inner_ratio - ang = math.radians(90 + i * (180.0 / points)) - x = cx + r * math.cos(ang) - y = cy + r * math.sin(ang) - if i == 0: - path.moveTo(x, y) - else: - path.lineTo(x, y) - path.close() - c.drawPath(path, stroke=0, fill=1) +def seal_layout(company, seal_number=SEAL_NUMBER): + """Shared geometry for the PDF seal and the inline SVG approval preview.""" + register_fonts() + company = (company or COMPANY_NAME).strip() + seal_number = (seal_number or SEAL_NUMBER).strip() + star = [] + for i in range(10): + radius = 9.2 if i % 2 == 0 else 9.2 * 0.382 + angle = math.radians(90 + i * 36) + star.append((50 + radius * math.cos(angle), 58 + radius * math.sin(angle))) + separators = [(50 + 38 * math.cos(math.radians(a)), + 50 + 38 * math.sin(math.radians(a))) for a in (205, 335)] + return {'company': company, 'number': seal_number, + 'arcs': [_seal_arc(company, 32.2, 200, 220, FONT_SEAL, 10.4, + scale_x=0.8, scale_y=1.18), + _seal_arc(seal_number, 41.5, 210, 120, FONT_SEAL_NUMBER, + 5.8, bottom=True, scale_x=0.9)], + 'title': {'font': FONT_SEAL, 'size': 9.2, 'scale_x': 0.94, 'scale_y': 1.12}, + 'star': star, 'separators': separators} class SealFlowable(Flowable): - """红色圆形电子章:外圈 + 上缘公司名 + 五角星 + 中部用途 + 下缘审核人/日期。""" + """公司名和印章号码环绕一圈的采购章,不含个人及日期。""" - def __init__(self, company, title, who, when, size=28 * mm): + def __init__(self, company, title, seal_number=SEAL_NUMBER, size=32 * mm): Flowable.__init__(self) - self.company = company or u"" - self.title = title or u"审核专用章" - self.who = who or u"" - self.when = when or u"" + self.layout = seal_layout(company, seal_number) + self.title = title or u"采购专用章" self.width = size self.height = size def draw(self): c = self.canv - w, h = self.width, self.height - cx, cy = w / 2.0, h / 2.0 - r = min(w, h) / 2.0 - 1.2 * mm c.saveState() + c.scale(self.width / 100, self.height / 100) c.setFillColor(SEAL_COLOR) c.setStrokeColor(SEAL_COLOR) c.setLineWidth(1.5) - c.circle(cx, cy, r, stroke=1, fill=0) + c.circle(50, 50, 46, stroke=1, fill=0) c.setLineWidth(0.5) - c.circle(cx, cy, r - 1.1 * mm, stroke=1, fill=0) - # 上缘公司名 - _arc_text(c, self.company, cx, cy, r - 3.2 * mm, 152, -124, FONT_BODY, 5.8) - # 中心五角星 - _star(c, cx, cy + 2.4 * mm, 2.8 * mm) - # 中部用途 - c.setFont(FONT_BOLD, 8.0) - c.drawCentredString(cx, cy - 4.2 * mm, self.title) - # 下缘审核人 + 日期 - line = u" ".join(x for x in (self.who, self.when) if x) - if line: - c.setFont(FONT_BODY, 5.4) - c.drawCentredString(cx, cy - 9.0 * mm, line) + c.circle(50, 50, 44.6, stroke=1, fill=0) + for arc in self.layout['arcs']: + c.setFont(arc['font'], arc['size']) + for letter in arc['letters']: + c.saveState() + c.translate(letter['x'], letter['y']) + c.rotate(letter['rotation']) + c.scale(arc['scale_x'], arc['scale_y']) + c.drawCentredString(0, 0, letter['text']) + c.restoreState() + for x, y in self.layout['separators']: + c.circle(x, y, 0.65, stroke=0, fill=1) + star = c.beginPath() + for i, (x, y) in enumerate(self.layout['star']): + (star.moveTo if i == 0 else star.lineTo)(x, y) + star.close() + c.drawPath(star, stroke=0, fill=1) + title_style = self.layout['title'] + c.setFont(title_style['font'], title_style['size']) + c.translate(50, 34) + c.scale(title_style['scale_x'], title_style['scale_y']) + c.drawCentredString(0, 0, self.title) c.restoreState() @@ -144,18 +171,25 @@ def register_fonts(): raise RuntimeError(u"未找到中文字体,请确认系统已安装宋体或黑体") pdfmetrics.registerFont(TTFont(FONT_BODY, body, subfontIndex=0)) pdfmetrics.registerFont(TTFont(FONT_BOLD, bold or body, subfontIndex=0)) + pdfmetrics.registerFont(TTFont(FONT_SEAL, _find_font(SEAL_CANDIDATES) or body, + subfontIndex=0)) + number_font = _find_font(SEAL_NUMBER_CANDIDATES) + if number_font: + pdfmetrics.registerFont(TTFont(FONT_SEAL_NUMBER, number_font, subfontIndex=0)) + else: + pdfmetrics.registerFont(pdfmetrics.Font(FONT_SEAL_NUMBER, 'Helvetica', 'WinAnsiEncoding')) def _fmt_money(v): try: - return "{:,.2f}".format(float(v or 0)) + return "{:,.2f}".format(money(v)) except (TypeError, ValueError): return "0.00" def _fmt_num(v): try: - return "{:g}".format(round(float(v or 0), 4)) + return format_price(v) except (TypeError, ValueError): return "" @@ -169,11 +203,11 @@ def P(text, style): def build_pdf(order, items, out_path, watermark=None): register_fonts() if watermark is None: - watermark = STATUS_WATERMARK.get(order.status) + watermark = None if order.status == 'approved' else STATUS_WATERMARK.get(order.status, '未批准') page_w, page_h = A4 margin_x = 11 * mm - doc = BaseDocTemplate(out_path, pagesize=A4, + doc = BaseDocTemplate(io.BytesIO(), pagesize=A4, leftMargin=margin_x, rightMargin=margin_x, topMargin=11 * mm, bottomMargin=16 * mm, title=u"%s %s" % (order.doc_type_text, order.order_no), @@ -224,7 +258,7 @@ def build_pdf(order, items, out_path, watermark=None): def make_story(): story = [] - story.append(P(COMPANY_NAME, styles["company"])) + story.append(P(order.our_company or COMPANY_NAME, styles["company"])) story.append(Spacer(1, 3 * mm)) story.append(P(order.doc_type_text or DOC_TYPES.get(order.doc_type, u""), styles["title"])) @@ -251,7 +285,7 @@ def build_pdf(order, items, out_path, watermark=None): ] head_data = [[P(l1 + (v1 or ""), styles["cell"]), P(l2 + (v2 or ""), styles["cell"])] for (l1, v1), (l2, v2) in zip(left_rows, right_rows)] - head = Table(head_data, colWidths=[93 * mm, 93 * mm], rowHeights=[6.6 * mm] * 7) + head = Table(head_data, colWidths=[93 * mm, 93 * mm]) head.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), @@ -276,16 +310,16 @@ def build_pdf(order, items, out_path, watermark=None): headers = [u"序号", u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价", u"金额", u"交期", u"备注"] data = [[P(h, styles["th"]) for h in headers]] - total = 0.0 + total = ZERO for idx, it in enumerate(items, 1): - total += float(it.amount or 0) + total += money(it.amount) data.append([ P(str(idx), styles["cell_c"]), P(it.material_code, styles["cell"]), P(it.material_spec, styles["cell"]), P(it.unit, styles["cell_c"]), P(_fmt_num(it.qty), styles["cell_c"]), - P(_fmt_money(it.price), styles["cell_c"]), + P(format_price(it.price), styles["cell_c"]), P(_fmt_money(it.amount), styles["cell_c"]), P(it.delivery_date, styles["cell_c"]), P(it.remark, styles["cell"]), @@ -311,16 +345,15 @@ def build_pdf(order, items, out_path, watermark=None): P(_fmt_money(total), styles["cell_c"])] + [P("", styles["cell"]) for _ in range(5)], [P(u"合计(人民币)", styles["cell"]), P("", styles["cell"]), P(cn_total, styles["cell_c"])] + [P("", styles["cell"]) for _ in range(4)] + - [P(u"元整", styles["cell"]), P("", styles["cell"])], + [P("", styles["cell"]), P("", styles["cell"])], ] - sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm]) + sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm], splitByRow=0) sum_t.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")), ("SPAN", (0, 0), (2, 0)), ("SPAN", (3, 0), (8, 0)), ("SPAN", (0, 1), (1, 1)), - ("SPAN", (2, 1), (6, 1)), - ("SPAN", (7, 1), (8, 1)), + ("SPAN", (2, 1), (8, 1)), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), @@ -343,35 +376,15 @@ def build_pdf(order, items, out_path, watermark=None): def stamp(name, dt): return (u"%s %s" % (name, dt.strftime("%Y-%m-%d %H:%M"))) if dt else (name or u"待处理") - sign_line = u"制单:%s 主管审核:%s 经理批准:%s" % ( + sign_line = u"制单:%s\n审批:%s" % ( stamp(order.created_by_name, order.created_at), - stamp(order.reviewed_by, order.reviewed_at), stamp(order.approved_by, order.approved_at)) confirm_line = (u"供方确认(盖章):__________________ " u"需方确认(盖章):__________________") - # 已审核 / 已批准 → 在签署区右侧盖章(两枚时并排) - seals = [] - if order.reviewed_by: - seals.append(SealFlowable( - order.our_company or COMPANY_NAME, u"审核专用章", order.reviewed_by, - order.reviewed_at.strftime("%Y-%m-%d") if order.reviewed_at else 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: - seals.append(SealFlowable( - order.our_company or COMPANY_NAME, u"批准专用章", order.approved_by, - order.approved_at.strftime("%Y-%m-%d") if order.approved_at else u"")) - if seals: - if len(seals) == 1: - seal_cell = seals[0] - else: - inner = Table([seals], colWidths=[29 * mm, 29 * mm]) - inner.setStyle(TableStyle([ - ("VALIGN", (0, 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), - ])) - seal_cell = inner + 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"])]], @@ -393,5 +406,6 @@ def build_pdf(order, items, out_path, watermark=None): doc.build(make_story()) counters["total"] = doc.page + doc.filename = out_path doc.build(make_story()) return out_path diff --git a/po_app/requirements.txt b/po_app/requirements.txt index e4b5ec0..95d3575 100644 --- a/po_app/requirements.txt +++ b/po_app/requirements.txt @@ -1,5 +1,6 @@ -Flask>=3.0 -SQLAlchemy>=2.0 -reportlab>=4.0 -openpyxl>=3.1 -xlrd>=2.0 +Flask>=3.0,<4 +SQLAlchemy>=2.0,<3 +reportlab>=4.0,<5 +openpyxl>=3.1,<4 +xlrd>=2.0,<3 +waitress>=3.0,<4 diff --git a/po_app/security.py b/po_app/security.py new file mode 100644 index 0000000..a037524 --- /dev/null +++ b/po_app/security.py @@ -0,0 +1,220 @@ +"""Request-scoped transactions, authentication and account administration helpers.""" +import os +import secrets +import re +from functools import wraps +from pathlib import Path + +from flask import (abort, current_app, flash, g, redirect, render_template, + request, session, url_for) +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm.exc import StaleDataError +from werkzeug.local import LocalProxy +from werkzeug.security import check_password_hash, generate_password_hash + +from models import User + + +def next_page(value): + """Only preserve known local destinations from an approval notification.""" + if value in ('/', '/orders'): + return value + if isinstance(value, str) and re.fullmatch(r'/orders/\d+/(?:view|versions/\d+)', value): + return value + return '/' + + +def login_redirect(): + return redirect(url_for('login', next=next_page(request.path))) + + +def get_db(): + if 'db' not in g: + g.db = current_app.extensions['po_session_factory']() + return g.db + + +db = LocalProxy(get_db) + + +def current_user(): + uid = session.get('user_id') + if not isinstance(uid, int): + return None + user = db.get(User, uid) + if not user or not user.active or session.get('session_version') != user.session_version: + session.clear() + return None + return user + + +def login_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not current_user(): + return login_redirect() + return f(*args, **kwargs) + return wrapper + + +def role_required(*roles): + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + user = current_user() + if not user: + return login_redirect() + if user.role != 'admin' and user.role not in roles: + abort(403) + return f(*args, **kwargs) + return wrapper + return decorator + + +def csrf_token(): + if 'csrf_token' not in session: + session['csrf_token'] = secrets.token_hex(32) + return session['csrf_token'] + + +def validate_password(password): + if len(password) < 10 or len(password) > 128: + raise ValueError('密码须为 10 至 128 个字符') + if password in ('1234567890', 'admin123456', 'password123'): + raise ValueError('请勿使用常见密码') + + +def persistent_secret(path): + path = Path(path) + try: + with path.open('x', encoding='ascii') as f: + value = secrets.token_hex(32) + f.write(value) + except FileExistsError: + value = path.read_text(encoding='ascii').strip() + if len(value) < 32: + raise RuntimeError('本地密钥文件不完整,请检查 %s' % path) + return value + + +def sign_in(user): + session.clear() + session.update(user_id=user.id, user_name=user.name, role=user.role, + session_version=user.session_version) + + +def init_security(app, factory, instance): + app.extensions['po_session_factory'] = factory + app.secret_key = os.environ.get('PO_SECRET_KEY') or persistent_secret(Path(instance) / 'session.key') + if len(app.secret_key) < 32: + raise RuntimeError('PO_SECRET_KEY 至少需要 32 个字符') + app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax', + SESSION_COOKIE_SECURE=os.environ.get('PO_HTTPS') == '1') + with factory() as bootstrap_db: + if not bootstrap_db.query(User).count(): + persistent_secret(Path(instance) / 'setup.token') + + @app.before_request + def protect_request(): + if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'): + expected = session.get('csrf_token', '') + supplied = request.form.get('csrf_token') or request.headers.get('X-CSRF-Token', '') + if not expected or not secrets.compare_digest(expected.encode('utf-8'), supplied.encode('utf-8')): + abort(400, description='页面已过期,请刷新后重新操作') + # Serialize SQLite writes before reading state or allocating order numbers. + # Every request owns its Session; GET requests remain independent readers. + db.execute(text('BEGIN IMMEDIATE')) + user = current_user() + if user and user.must_change_password and request.endpoint not in ('change_password', 'logout', 'static'): + return redirect(url_for('change_password', next=next_page(request.path))) + + @app.teardown_appcontext + def close_db(error=None): + connection = g.pop('db', None) + if connection is not None: + connection.rollback() + connection.close() + + @app.errorhandler(ValueError) + def invalid_input(error): + db.rollback() + return render_template('error.html', message=str(error)), 400 + + @app.errorhandler(SQLAlchemyError) + def database_error(error): + db.rollback() + if isinstance(error, StaleDataError): + message, status = '订单已被其他人修改,请刷新后核对最新内容再操作。', 409 + elif isinstance(error, IntegrityError): + message, status = '数据已存在或被其他记录引用,请检查登录名、单号等内容后重试。', 409 + else: + app.logger.exception('Database request failed') + message, status = '暂时无法保存,请稍后重试;本次操作未生效。', 503 + return render_template('error.html', message=message), status + + @app.context_processor + def security_context(): + return {'csrf_token': csrf_token, 'request_key': lambda: secrets.token_hex(16), + 'current_user': current_user} + + @app.route('/setup', methods=['GET', 'POST']) + def setup(): + if db.query(User).count(): + return redirect(url_for('login')) + if request.method == 'POST': + expected = (Path(instance) / 'setup.token').read_text(encoding='ascii').strip() + if not secrets.compare_digest(expected.encode('utf-8'), request.form.get('setup_token', '').encode('utf-8')): + abort(403) + username = request.form.get('username', '').strip() + if not username or len(username) > 50: + raise ValueError('请填写不超过 50 字的管理员登录名') + password = request.form.get('password', '') + validate_password(password) + user = User(username=username, name=request.form.get('name', '').strip() or '系统管理员', + password_hash=generate_password_hash(password), role='admin', active=True, + session_version=1, must_change_password=False) + db.add(user) + db.commit() + sign_in(user) + return redirect(url_for('index')) + return render_template('setup.html') + + @app.route('/login', methods=['GET', 'POST']) + def login(): + if not db.query(User).count(): + return redirect(url_for('setup')) + if request.method == 'POST': + user = db.query(User).filter_by(username=request.form.get('username', '').strip()).first() + if user and user.active and check_password_hash(user.password_hash, request.form.get('password', '')): + sign_in(user) + destination = next_page(request.form.get('next')) + return redirect(url_for('change_password', next=destination) if user.must_change_password else destination) + flash('用户名或密码错误', 'error') + return render_template('login.html') + + @app.route('/logout', methods=['POST']) + def logout(): + session.clear() + return redirect(url_for('login')) + + @app.route('/account/password', methods=['GET', 'POST']) + @login_required + def change_password(): + user = current_user() + if request.method == 'POST': + old = request.form.get('old_password', '') + password = request.form.get('password', '') + if not check_password_hash(user.password_hash, old): + raise ValueError('原密码不正确') + validate_password(password) + if old == password or password != request.form.get('confirm_password'): + raise ValueError('新密码须与原密码不同,且两次输入一致') + user.password_hash = generate_password_hash(password) + user.must_change_password = False + user.session_version += 1 + db.commit() + sign_in(user) + flash('密码已更新,其他登录已失效', 'ok') + return redirect(next_page(request.form.get('next'))) + return render_template('password.html', user=user) diff --git a/po_app/static/money.js b/po_app/static/money.js new file mode 100644 index 0000000..bed911e --- /dev/null +++ b/po_app/static/money.js @@ -0,0 +1,22 @@ +/* The same rule as money.py: 4 decimal places, round each line half up to cents. */ +(function (root) { + function units(value) { + const s = String(value || '0').trim(); + if (!/^\d+(\.\d{0,4})?$/.test(s)) throw new Error('数量和单价须为非负数字,最多四位小数'); + const parts = s.split('.'); + const n = BigInt(parts[0]) * 10000n + BigInt((parts[1] || '').padEnd(4, '0')); + if (n > 9999999990000n) throw new Error('数量或单价过大'); + return n; + } + function lineCents(qty, price) { + const cents = (units(qty) * units(price) + 500000n) / 1000000n; + if (cents > 99999999999999n) throw new Error('金额过大'); + return cents; + } + function format(cents) { + return (cents / 100n).toString() + '.' + (cents % 100n).toString().padStart(2, '0'); + } + const api = {units, lineCents, format}; + if (typeof module !== 'undefined') module.exports = api; + root.POMoney = api; +})(typeof window === 'undefined' ? globalThis : window); diff --git a/po_app/static/style.css b/po_app/static/style.css index faeaa0a..607ce06 100644 --- a/po_app/static/style.css +++ b/po_app/static/style.css @@ -35,7 +35,7 @@ table.grid td.num, table.grid th.num { text-align: right; } .form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 16px; } .form-grid.two { grid-template-columns: repeat(2, 1fr); } label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } -input[type=text], input[type=password], input[type=number], input[type=date], select, textarea { +input:not([type]), input[type=text], input[type=password], input[type=number], input[type=date], select, textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccd3da; border-radius: 4px; font-size: 13px; font-family: inherit; background: #fff; } @@ -64,7 +64,8 @@ input:focus, select:focus, textarea:focus { outline: none; border-color: #1668dc .badge.pending_approval { background: #e6f0ff; color: #14509b; } .badge.approved { background: #e8f6ee; color: #12703f; } .badge.rejected { background: #fdecec; color: #a32026; } -.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 16px; } +.badge.voided { background: #eceef1; color: #555; text-decoration: line-through; } +.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; } .stat { background: #fff; border: 1px solid #e2e6ea; border-radius: 6px; padding: 12px 14px; } .stat .v { font-size: 22px; font-weight: 600; } .stat .k { font-size: 12px; color: #777; margin-top: 2px; } @@ -113,9 +114,31 @@ table.grid tr.row-off .btn { opacity: .8; } .seal-wrap { display: inline-block; padding: 6px 10px; border: 1px dashed #d9b3b3; border-radius: 6px; background: #fffafa; } .seal-tip { color: #12703f; font-size: 13px; font-weight: 600; margin-bottom: 6px; } -.seal-circle { width: 126px; height: 126px; border: 3px solid #C8102E; border-radius: 50%; - color: #C8102E; display: flex; flex-direction: column; align-items: center; - justify-content: center; text-align: center; } -.seal-circle .co { font-size: 9.5px; letter-spacing: .5px; line-height: 13px; padding: 0 8px; } -.seal-circle .txt { font-size: 16px; font-weight: 700; letter-spacing: 3px; margin: 5px 0; } -.seal-circle .who { font-size: 8.5px; line-height: 12px; } +.seal-preview { display: block; width: 180px; height: 180px; color: #C8102E; } +.seal-company { font-family: "FangSong", "STFangsong", "Songti SC", "SimSun", serif; font-weight: 400; } +.seal-code { font-family: Arial, "Helvetica Neue", sans-serif; font-weight: 400; } + +.notification-wide { grid-column: 1 / -1; } +.mobile-only { display: none; } +@media (max-width: 800px) { + .topbar { height: auto; min-height: 52px; flex-wrap: wrap; gap: 12px; padding: 12px 16px; } + .topbar .brand { width: 100%; } + .topbar nav { width: 100%; overflow-x: auto; padding-bottom: 6px; white-space: nowrap; } + .topbar .spacer { display: none; } + .topbar .me { flex: 1; } + .wrap { margin-top: 12px; padding: 0 10px; } + .card { padding: 14px 12px; overflow-x: auto; } + .form-grid, .form-grid.two { grid-template-columns: 1fr; } + .form-grid > [style*="grid-column"] { grid-column: auto !important; } + .stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + table.grid { min-width: 680px; } + table.approval-flow { min-width: 0; } + .mobile-only { display: block; } + .kv-table { min-width: 520px; } + .toolbar > * { max-width: 100%; } + .total-bar { flex-wrap: wrap; gap: 8px; } + .login-box { width: calc(100% - 32px); max-width: 380px; margin: 40px auto; } + h1.page { overflow-wrap: anywhere; line-height: 1.6; } + .btn { min-height: 40px; } + input:not([type=hidden]):not([type=checkbox]), select, textarea { font-size: 16px; } +} diff --git a/po_app/templates/base.html b/po_app/templates/base.html index e4924fb..bdbc0bc 100644 --- a/po_app/templates/base.html +++ b/po_app/templates/base.html @@ -2,6 +2,7 @@ + {% block title %}采购订单管理系统{% endblock %} @@ -12,15 +13,18 @@ {{ session.get('user_name') }}({{ ROLE_TEXT.get(session.get('role'), '') }}) - 退出 + 修改密码 +
+
{% endif %}
@@ -31,6 +35,6 @@ {% endwith %} {% block body %}{% endblock %}
-
{{ COMPANY_NAME }} · 采购订单管理系统 v1.0
+
{{ COMPANY_NAME }} · 采购订单管理系统 v3.0
diff --git a/po_app/templates/error.html b/po_app/templates/error.html new file mode 100644 index 0000000..bed5863 --- /dev/null +++ b/po_app/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block body %} +
+

本次操作未完成

+

{{ message }}

+ + 返回工作台 +
+{% endblock %} diff --git a/po_app/templates/index.html b/po_app/templates/index.html index fb8f0bf..3f25aee 100644 --- a/po_app/templates/index.html +++ b/po_app/templates/index.html @@ -2,11 +2,10 @@ {% block body %}

工作台

-
{{ counts.get('pending_review', 0) }}
待主管审核
-
{{ counts.get('pending_approval', 0) }}
待经理批准
-
{{ counts.get('approved', 0) }}
已批准
+
{{ counts.get('pending_approval', 0) }}
待审批
+
{{ counts.get('approved', 0) }}
审批通过
{{ counts.get('draft', 0) }}
草稿
-
{{ '%.2f'|format(month_total) }}
已批准订单总额(元)
+
{{ '%.2f'|format(month_total) }}
审批通过订单总额(元)
{% if todo %} diff --git a/po_app/templates/login.html b/po_app/templates/login.html index 98a0bdc..b69ca3b 100644 --- a/po_app/templates/login.html +++ b/po_app/templates/login.html @@ -2,6 +2,7 @@ + 登录 · 采购订单管理系统 @@ -12,6 +13,8 @@ {% for cat, msg in msgs %}
{{ msg }}
{% endfor %} {% endwith %}
+ +
@@ -22,10 +25,7 @@
-
- 演示账号:admin / admin123(管理员),maker / 123456(制单员), - reviewer / 123456(主管审核),approver / 123456(经理批准) -
+ diff --git a/po_app/templates/materials.html b/po_app/templates/materials.html index 0689784..5e5b420 100644 --- a/po_app/templates/materials.html +++ b/po_app/templates/materials.html @@ -4,6 +4,7 @@

新增物料

+
@@ -31,6 +32,7 @@ data-spec="{{ m.spec or '' }}" data-unit="{{ m.unit or '' }}" data-price="{{ m.price or 0 }}">编辑 + @@ -55,6 +58,7 @@ ×
+
', - html, re.S)) - - -def row_html(html, key): - """取出包含指定关键字的那个 ...。""" - for m in re.finditer(r"]*>(.*?)", html, re.S): - if key in m.group(1): - return m.group(0) - return "" - - -def dbq(sql, args=()): - con = sqlite3.connect(DB) - cur = con.cursor() - cur.execute(sql, args) - r = cur.fetchone() - con.close() - return r - - -def main(): - admin = new_sess("admin", "admin123") - - print("\n[1] 供应商:停用 → 整行置灰 → 启用 → 彻底删除") - admin.post(BASE + "/suppliers", - data={"code": "S-T1", "name": u"测试供应商T1", "contact": u"测试联系人"}) - sid = dbq("SELECT id FROM suppliers WHERE code='S-T1'")[0] - h = admin.get(BASE + "/suppliers").text - check("S-T1 已创建", "S-T1" in h) - check("初始为启用态(未置灰)", "row-off" not in row_html(h, "S-T1")) - admin.post(BASE + "/suppliers/%d/toggle" % sid) - h = admin.get(BASE + "/suppliers").text - check("停用后整行置灰", "row-off" in row_html(h, "S-T1")) - check("停用后按钮变为『启用』", u"启用" in row_html(h, "S-T1")) - check("停用后仍可编辑(保留入口)", "openEdit" in row_html(h, "S-T1")) - admin.post(BASE + "/suppliers/%d/toggle" % sid) - h = admin.get(BASE + "/suppliers").text - check("启用后恢复(不再置灰)", "row-off" not in row_html(h, "S-T1")) - admin.post(BASE + "/suppliers/%d/delete" % sid) - h = admin.get(BASE + "/suppliers").text - check("删除后从列表消失", "S-T1" not in h) - - print("\n[2] 物料:停用 → 整行置灰 → 启用 → 彻底删除") - admin.post(BASE + "/materials", - data={"code": "M-T1", "spec": u"测试物料T1", "unit": u"件", "price": "9.9"}) - mid = dbq("SELECT id FROM materials WHERE code='M-T1'")[0] - h = admin.get(BASE + "/materials").text - check("M-T1 已创建", "M-T1" in h) - check("初始为启用态(未置灰)", "row-off" not in row_html(h, "M-T1")) - admin.post(BASE + "/materials/%d/toggle" % mid) - h = admin.get(BASE + "/materials").text - check("停用后整行置灰", "row-off" in row_html(h, "M-T1")) - admin.post(BASE + "/materials/%d/toggle" % mid) - h = admin.get(BASE + "/materials").text - check("启用后恢复", "row-off" not in row_html(h, "M-T1")) - admin.post(BASE + "/materials/%d/delete" % mid) - h = admin.get(BASE + "/materials").text - check("删除后从列表消失", "M-T1" not in h) - - print("\n[3] 主管审核:确认前必须先加章") - r = admin.post(BASE + "/orders/new", data={"doc_type": "purchase"}) - m = re.search(r"/orders/(\d+)/edit", r.url) - oid = int(m.group(1)) - admin.post(BASE + "/orders/%d" % oid, data={ - "supplier_name": u"测试供应商", - "item_code": ["M-1001"], "item_spec": [u"测试物料"], - "item_unit": [u"件"], "item_qty": ["10"], "item_price": ["12.5"], - "item_date": [""], "item_remark": [""], - }) - admin.post(BASE + "/orders/%d/submit" % oid, data={"comment": u"模拟提交"}) - st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] - check("订单已提交,状态待审核", st == "pending_review", st) - - rv = new_sess("reviewer", "123456") - r = rv.post(BASE + "/orders/%d/review" % oid, - data={"comment": u"直接点确认试试", "stamped": "0"}) - check("未加章 → 被拒绝并提示", u"请先加盖审核章" in flash_of(r.text), - flash_of(r.text)) - st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] - check("未加章时状态不变", st == "pending_review", st) - - r = rv.post(BASE + "/orders/%d/review" % oid, - data={"comment": u"已核对无误", "stamped": "1"}) - row = dbq("SELECT status, reviewed_by FROM orders WHERE id=?", (oid,)) - check("加章后审核通过", row[0] == "pending_approval", row) - check("记录了审核人", bool(row[1]), row[1]) - - print("\n[4] 导出带章 PDF 并渲染预览") - pdf_bytes = admin.get(BASE + "/orders/%d/pdf" % oid).content - pdf_path = os.path.join(OUT, "seal_review.pdf") - with open(pdf_path, "wb") as f: - f.write(pdf_bytes) - check("PDF 已生成", len(pdf_bytes) > 10000, "%d bytes" % len(pdf_bytes)) - try: - import fitz - doc = fitz.open(pdf_path) - pix = doc[-1].get_pixmap(dpi=130) - png_path = os.path.join(OUT, "seal_review.png") - pix.save(png_path) - print(" 渲染预览:", png_path, "共 %d 页" % doc.page_count) - doc.close() - except Exception as e: - print(" 渲染预览失败:", e) - - print("\n[5] 清理测试订单") - con = sqlite3.connect(DB) - cur = con.cursor() - cur.execute("DELETE FROM order_items WHERE order_id=?", (oid,)) - cur.execute("DELETE FROM orders WHERE id=?", (oid,)) - con.commit() - con.close() - print(" 已删除订单 %d" % oid) - - print("\n" + ("模拟全部通过" if not FAILS else "失败: " + "; ".join(FAILS))) - +"""Compatibility entry point; tests only use temporary databases.""" +from run_tests import main if __name__ == "__main__": - main() + raise SystemExit(main(('-k', 'full_flow'))) diff --git a/tools/verify_exe.py b/tools/verify_exe.py index 4e0204f..d474ba4 100644 --- a/tools/verify_exe.py +++ b/tools/verify_exe.py @@ -1,82 +1,44 @@ -# -*- coding: utf-8 -*- -"""打包后 EXE 冒烟测试。 - -确认这些确实进了包:批量导入入口与模板、列表编辑入口、弹窗样式, -以及「新建订单明细为空时仍能添加行」所依赖的 rowTpl 模板。 - -用法:python verify_exe.py 8080 [数据库路径] - 不传数据库路径时默认 po_app/dist/instance/po.db -""" -import os +"""Read-only page/asset checks for a running packaged app; never writes order data.""" +import argparse +import getpass +import http.cookiejar import re -import sqlite3 -import sys - -import requests - -BASE = "http://127.0.0.1:%s" % (sys.argv[1] if len(sys.argv) > 1 else "8080") -HERE = os.path.dirname(os.path.abspath(__file__)) -DB = sys.argv[2] if len(sys.argv) > 2 else os.path.join( - os.path.dirname(HERE), "po_app", "dist", "instance", "po.db") - -s = requests.Session() -FAILS = [] - - -def check(label, cond, extra=""): - print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else "")) - if not cond: - FAILS.append(label) +import urllib.parse +import urllib.request def main(): - r = s.post(BASE + "/login", data={"username": "admin", "password": "admin123"}) - check("登录", r.status_code == 200, r.status_code) - - # 注意:url_for() 会被 Jinja 渲染成实际路径,页面里不会出现函数名 - for path, marks in ( - ("/materials", [u"批量导入 Excel", "matImpModal", "/materials/import_excel", - "/materials/import_template.xlsx", "openEdit", "editModal", - "/toggle", "/delete", u"停用", u"删除"]), - ("/suppliers", [u"批量导入 Excel", "supImpModal", "/suppliers/import_excel", - "/suppliers/import_template.xlsx", "openEdit", "editModal", - "/toggle", "/delete", u"停用", u"删除"]), - ): - h = s.get(BASE + path).text - missing = [m for m in marks if m not in h] - check("%s 含导入/编辑/停用删除/置灰" % path, not missing, missing) - - css = s.get(BASE + "/static/style.css").text - check("style.css 含弹窗样式", "modal-mask" in css and "modal-box" in css) - - for u in ("/materials/import_template.xlsx", "/suppliers/import_template.xlsx"): - r = s.get(BASE + u) - check("模板下载 %s" % u, r.status_code == 200 and len(r.content) > 3000, - "%s bytes" % len(r.content)) - - # 新建订单:明细为空时必须仍能添加行(依赖 rowTpl 模板存在) - r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"}) - h = r.text - no = "" - m = re.search(r"PO\d{12}", h) - if m: - no = m.group(0) - check("新建订单页含 rowTpl 明细行模板", 'id="rowTpl"' in h) - tpl = re.search(r'', h, re.S) - check("rowTpl 内含完整空行", bool(tpl) and 'name="item_code"' in tpl.group(1)) - - if no and os.path.exists(DB): - con = sqlite3.connect(DB) - cur = con.cursor() - cur.execute("DELETE FROM order_items WHERE order_id IN " - "(SELECT id FROM orders WHERE order_no=?)", (no,)) - cur.execute("DELETE FROM orders WHERE order_no=?", (no,)) - con.commit() - con.close() - print(" 已清理测试订单 %s" % no) - - print("\n" + ("打包版冒烟全部通过" if not FAILS else "失败: " + "; ".join(FAILS))) + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--url',default='http://127.0.0.1:8080') + parser.add_argument('--username',required=True,help='Use an initialized admin account') + args=parser.parse_args() + base=args.url.rstrip('/') + opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + def get(path): + with opener.open(base+path,timeout=30) as response: + return response.geturl(),response.read() + _,body=get('/login') + match=re.search(r'name="csrf_token" value="([^"]+)"',body.decode('utf-8')) + if not match: + raise SystemExit('Complete first-time setup before running this check.') + fields={'username':args.username,'password':getpass.getpass('Password: '),'csrf_token':match[1]} + with opener.open(urllib.request.Request(base+'/login',data=urllib.parse.urlencode(fields).encode()),timeout=30) as response: + if response.geturl().rstrip('/') != base: + raise SystemExit('Login failed or password change is required.') + response.read() + for path,marker in [('/orders/new',b'request_key'),('/users',b'username'),('/materials',b'matImpModal'), + ('/suppliers',b'supImpModal'),('/static/money.js',b'lineCents'),('/static/style.css',b'modal-mask')]: + url,body=get(path) + if url != base+path or marker not in body: + raise SystemExit('Failed page/asset check: '+path) + print('PASS',path) + for path in ['/materials/import_template.xlsx','/suppliers/import_template.xlsx','/import_template.xlsx']: + url,body=get(path) + if url != base+path or not body.startswith(b'PK'): + raise SystemExit('Failed workbook check: '+path) + print('PASS',path) + print('Packaged page/asset checks passed; order data was not modified.') -if __name__ == "__main__": +if __name__=='__main__': main() diff --git a/运行与升级说明.md b/运行与升级说明.md new file mode 100644 index 0000000..7df8f17 --- /dev/null +++ b/运行与升级说明.md @@ -0,0 +1,135 @@ +# 采购订单系统 v3:运行、升级与验收 + +当前支持采购订单生成、单次内部审批、企业微信群审批通知和公司 Wi-Fi 内手机审批。未接入金额分级、组织架构或第三方电子签章。 + +## 新电脑首次运行 + +1. 安装 Python 3.11 或更高版本,在项目根目录执行 `python -m pip install -r po_app/requirements.txt`。 +2. 执行 `python po_app/app.py 8080`,或双击 `po_app/启动.bat`。 +3. 用浏览器打开 `http://127.0.0.1:8080`。首次运行跳转到管理员初始化页面。 +4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。 +5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。 +6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。 + +系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。 + +多台电脑协作时,在一台固定电脑运行服务,其他电脑访问这台主机的局域网 IP 和实际端口。不要每人启动一套独立 EXE,否则各自数据库互不相通。关闭服务主机的程序会使其他电脑暂时无法访问。 + +已有 PyInstaller 配置可继续用于重新打包;本次源码修复不等于旧 EXE 已更新。运行新版 EXE 前须重新构建并验收。服务若经过 HTTPS 代理,可设置 `PO_HTTPS=1` 启用 Secure Cookie;纯 HTTP 局域网环境不要设置此项。 + +## 升级已有数据 + +1. 关闭旧版本服务,并妥善备份原数据目录。不要把旧程序和新程序同时指向同一数据库。 +2. 更新源码并安装依赖,使用相同数据目录启动。 +3. 程序首次升级已有 `instance/po.db` 时会先创建 `po.db.pre-upgrade-时间.bak`,再在事务中增加所需字段并迁移金额。原来的单号、订单、明细和日志保留。 +4. 如果检测到重复单号,程序会停止升级并列出冲突,原数据不改动。先由业务负责人确认正确单号后再处理;系统不会自行给历史订单改号。 +5. 从最初的旧版升级时,旧登录失效,账号须修改密码;从 v2 升级到 v3 时,原主管角色改为审批员并使其旧登录失效,其余账号保留当前密码策略。请核对账号角色及实际使用人。 +6. 升级前没有记录的提交快照无法补造。历史日志仍可查看,后续提交和审批会形成新快照。需要保存旧的正式文件时,应将原导出文件与旧数据库备份一起保管。 + +升级后金额在数据库内采用整数存储:数量、单价放大 10000 倍;行金额和总金额以分保存。旧版小数值按四舍五入迁移至对应精度,历史批准金额不自动按新规则重算。后续再次提交或审批时会按新规则校验并计算。 + +新版程序会生成独立的 `instance/session.key`,不再使用源码固定密钥。也可通过至少 32 字符的 `PO_SECRET_KEY` 提供部署密钥。更换密钥会使全部旧登录失效。 + +### v2 两级审批合并为 v3 单次审批 + +- 升级同样先备份数据库,再在事务中迁移。原“主管审核”账号统一改为“审批员”,原“经理批准”账号也显示为“审批员”;由任一合格审批员一次确认完成审批,制单人不能审批自己的订单。 +- 原“待主管审核”和“待经理批准”均转为“待审批”,不会自动通过。两种状态的订单版本号都会递增,旧页面须刷新;原“审核通过”接口停止受理,避免将旧的一审确认误当成最终审批。 +- 既有审核人、审核时间和历史日志保留,在详情页标为“旧流程审核(历史)”。已归档 PDF 的文件内容、摘要和模板版本均保留,不给旧文件重新换章。新完成的审批使用单枚“采购专用章”。 +- 企业微信只保留“审批通知群”和可选的“结果通知群”。默认沿用原经理批准群;若原批准群为空,则沿用原主管审核群。请管理员核对群成员,按需把原主管加入新的审批群。 +- 尚未发送且仍有效的旧待办改用统一审批群;过期、已处理的旧待办及旧测试通知取消。发送成功的通知记录保留,历史操作不补发。已达到重试上限的记录仍需管理员检查后手动重试。 +- 升级不会修改已完成审批的业务状态。降级时必须停服务并恢复升级前备份,不能直接用 v2 程序维护已转换为单次审批的数据。 + +## 操作规则 + +- 制单员只可访问自己的订单,审批员和管理员可查看订单全集;读取、复制、PDF、台账和统计遵守一致的数据范围。 +- 制单员及管理员可制单。审批员一次确认即可完成审批,但不能审批自己制的订单;管理员也遵守分工限制。 +- 驳回只能由当前审批环节的合格人员执行,并必须填写原因。 +- 编辑页使用“保存并提交审批”一次保存当前内容并提交。“保存草稿”允许尚未填写完的内容;负数、非法数字和过多小数位仍会被拒绝。 +- 正式提交需要供应商、需方、付款方式、交货地点、采购日期,以及有效物料、单位、正数量、正单价和交期。当前规则不允许零价订单;若需要样品或赠品,须先定义单独的业务规则。 +- 数量和单价最多四位小数。每行金额按数量乘单价四舍五入到分,合计为各行金额之和;页面和 PDF 均按此口径展示。 +- 制单人可撤回待审批订单,修改后重新提交。原提交内容保留在版本记录中。 +- 已批准订单由制单人或管理员发起变更后回到草稿,原批准停止使用,必须重新审批。已发出的旧文件不能远程撤销,应另行通知接收人。 +- 草稿、驳回单可由有编辑权限的人作废;已批准单由审批员或管理员作废。作废订单不可编辑,导出显示作废水印。 +- 每次提交产生不可通过业务页面修改的内容快照。批准时保存 PDF 文件及摘要,后续下载复用该文件;页面图形印章表示内部审批确认,不是第三方证书数字签名。 +- 用户页支持新增、编辑、重置密码、启用和停用。改密或修改用户资料会使其原有会话失效;系统必须保留一个启用的管理员。 + +## 备份和恢复 + +运行 `python tools/backup_db.py` 创建一致性数据库备份,文件位于 `po_app/instance/backups/`。自定义数据目录时使用 `python tools/backup_db.py --data-dir 数据目录`。备份包含订单、日志、版本、已归档的正式 PDF,以及通知设置和发送队列;工具在备份后执行 SQLite 完整性检查。Webhook 发送凭据保存在本机数据库中,未额外加密;配置通知后,数据库和备份应仅允许受信任管理员读取,不要提交到 Git 或公开分享。 + +同时定期保管数据目录内的密钥、需要保留的上传文件和旧版导出文件。备份应保存到另一台电脑或独立介质;仅在同一磁盘上留副本不能应对磁盘故障。 + +恢复时先停止服务,保留当前数据目录副本,再用经确认的备份替换 `instance/po.db`,启动后核对订单数量、最新单号、关键订单明细和审批记录。降回旧版程序时,应使用升级前备份;新版固定精度字段不能由旧版程序维护。 + +恢复旧备份可能把已发送通知恢复成待发送状态。正式启动前,应在禁用外网发送的维护环境中核对并关闭通知配置,再按需启用,避免历史通知重发。 + +## 企业微信通知与公司 Wi-Fi 手机审批 + +### 服务主机与手机 + +1. 在公司内固定一台服务电脑,通过 `python po_app/app.py 8080` 或配套启动程序运行。给它设置固定内网 IP 或 DHCP 地址保留,例如 `192.168.1.10`,保持开机并关闭自动休眠。 +2. Windows 防火墙只需允许公司专用网络内的手机和电脑访问实际服务端口。不要设置公网端口映射。`PO_HOST` 使用默认 `0.0.0.0` 或主机内网 IP,不能只绑定 `127.0.0.1`。 +3. 手机连公司 Wi-Fi,在浏览器访问 `http://192.168.1.10:8080`。须使用实际 IP 和控制台实际端口;不要把示例地址直接当作本机地址。访客 Wi-Fi、无线客户端隔离或不同 VLAN 可能需要网管开通相互访问。 +4. 审批员使用自己的系统账号。企业微信链接打开后如要求登录,登录及首次改密后会回到原订单。只打开链接不产生审批;仍需点击盖章再确认,或填写原因驳回。明细表可在卡片内左右滑动。 + +本方案通过公司内网访问订单,不提供外网审批和企业微信免密登录。手机离开公司网络后仍可能收到企业微信通知,但订单链接需回到公司 Wi-Fi 后打开。企业微信客户端若限制打开内网 HTTP 链接,可复制链接到手机浏览器访问;实际客户端行为须用公司的手机现场确认。 + +### 配置企业微信群 + +1. 为审批人员建立一个企业微信群,将需要接收待办的审批员加入该群。按 [腾讯云官方 Webhook 获取说明](https://cloud.tencent.com/document/product/1759/128391) 在 PC 企业微信的群管理中创建自定义消息推送并复制 Webhook。 +2. 管理员进入系统“通知配置”,填写手机可访问的内网根地址以及审批通知群的完整 Webhook。内网地址与该群均配置后才能启用;结果通知群为可选项。 +3. 结果群接收批准、驳回、撤回、变更和作废的通知。需要让制单人员收到结果时,将其加入该群;当前实现按群推送,不按订单制单人私信。 +4. 保存并启用后,点击“测试审批通知群”,在“最近 100 条通知”刷新查看状态,并在真实群内确认收到消息。保存配置本身不会发送测试消息。 +5. 用一张测试订单完成提交和一次审批,在手机上确认制单与审批分工、登录回跳和正式 PDF。通知中的待办若已处理,打开时显示最新状态,不会重复审批。 + +Webhook 保存后不再回显;留空保留已有值,“清除此群配置”才会删除。更换主机 IP 或端口时同步修改内网地址。域名模式需要由公司 DNS 解析到内网主机,程序不会替网管验证 DNS 或配置防火墙。 + +服务主机需要能够向 `https://qyapi.weixin.qq.com` 发起 HTTPS 请求;公司完全断网时可继续内网审批,但无法推送企业微信。无需企业微信从公网回调本系统。通知正文仅含单号、环节、提交版本、记录时间和链接,不发送明细金额、银行信息、审批意见或密码。消息格式遵循 [腾讯 CloudBase 的企业微信群机器人接口说明](https://docs.cloudbase.net/recipes/connect-wecom-webhook-cloud-function)。 + +### 发送队列与重试 + +- 通知默认关闭。只有启用后发生的新状态变更进入队列,历史操作不补发。新增配置和队列表会在启动时自动建立。 +- 待办通知随审批事务一起保存,后台发送失败不会撤销订单提交或批准。启动主程序会自动运行发送线程;仅导入 `app` 用于其他 WSGI 部署时,需显式调用 `start_notification_worker(app, Session)`,并在退出时设置返回的停止事件。 +- 本实例所有通知群合计每 4 秒最多尝试一条。建议给本系统专用机器人,避免其他系统共用机器人导致超频。 +- 首次失败后分别等待 30、60、120、240 秒重试,累计最多 5 次;到达上限后由管理员检查网络、机器人状态和配置,再点击“重试”。 +- 发送前核对待办的订单状态与提交版本,已撤回、已处理或重提的旧待办跳过。批准、驳回等结果通知记录发生时的事件,订单实时状态以链接页为准。 +- 禁用通知会暂停发送,重新启用后继续处理有效的队列项。移除某个可选群配置后,其未发送通知会跳过。修改群地址后,未发送通知使用新的群地址。 +- 网络超时或进程中断可能发生“企业微信已收到、系统尚未记为成功”,因此重试存在重复消息的可能。订单操作仍受登录、CSRF 和版本校验保护。 + +### 电子章及文件 + +手机和电脑审批使用同一套服务端逻辑。审批员点击“加盖采购专用章”并确认审批通过时,记录审批人、时间和日志,生成带一枚红色“采购专用章”的正式 PDF,连同 SHA-256 摘要保存到该次提交版本中,后续下载校验摘要并复用同一文件。 + +采购专用章内不显示审批员姓名和日期。审批人及时间保留在订单审批记录和 PDF 的签署信息中,便于追溯。章面调整适用于新生成文件,已归档 PDF 保持原文件。 + +图章外圈上半圈为“苏州博特维智能科技有限公司”,下半圈只显示印章号码“3205070994140”,两侧圆点分隔,环绕一圈,不带文字前缀或冒号。PDF 图章尺寸为 32 mm,公司名参考实物章使用细长仿宋字形,横向收窄、纵向拉长;号码使用较小的无衬线字体,缩短所占弧长。中间保留五角星和仿宋“采购专用章”。页面使用 SVG 预览,与 PDF 共用排字坐标和字形缩放参数;中文优先使用系统仿宋,缺少时回退到宋体。公司名读取订单的需方名称,印章号码单独设置,不使用或修改订单税号。 + +变更重审会清除当前审批信息,保留原提交和归档记录;作废单的当前导出带作废水印。已下载或已发给供应商的旧文件不能远程收回,须另行通知接收人停止使用。这些章是内部审批图形章,当前没有接入公司印章图片、第三方签章服务或证书数字签名。 + +## 开发验收 + +```text +python -m pip install -r requirements-dev.txt +python -m pytest tests -q +node tests/test_money_js.cjs +python tools/browser_smoke.py +``` + +浏览器检查使用本机 Microsoft Edge 的无界面模式,不操作用户已打开的浏览器。会启动临时本地服务、创建临时数据库,输出截图和 PDF 样本到 `tools/out/ui-check-*`。审批员使用手机尺寸与触控模拟;通知使用虚构 Webhook 和模拟发送器,不向真实企业微信群发送。其他单元和接口测试也仅使用临时数据库。 + +原 `tools/e2e_test.py`、`run_check.py` 和测试入口已经转为调用隔离回归用例,不再依赖原开发电脑路径或在线业务数据库。 + +验收应至少覆盖:制单与审批两个账号完成正常流程、驳回重提、撤回、作废和变更重审;他人订单访问被拒绝;重复请求不重复建单;并发审批只有一次生效;无效输入不破坏原内容;中文 XLS/XLSX 日期;页面与正式 PDF 金额一致;旧数据迁移、自动备份和恢复核验。 + +## 本轮验证结果(2026-09-16) + +- 65 项隔离回归用例全部通过,覆盖认证、权限、审批、精度、导入、账号维护、并发、迁移、备份恢复,以及通知事务一致性、失败重试、过期跳过、并发领取、凭据保护和登录回跳。 +- Node.js 金额预览验证通过:逐行舍入合计、半分四舍五入、四位单价及非法数值处理。 +- Microsoft Edge 无界面浏览器完成首次设置、用户创建、从订单链接登录及首次改密、采购单和委外单的手机尺寸单次审批、PDF 下载、通知配置和中文 XLSX 原生日期导入,未发现页面脚本错误。320、390、768 像素宽度下工作台、列表及订单页无整页横向溢出;宽表格可在卡片内滑动。 +- 最新浏览器记录位于 `tools/out/ui-check-yiosqkq7/`。模拟发送收到 1 条测试消息与 2 条批准结果,2 条已经完成的待办被跳过;未发出真实企业微信消息。 +- PDF 最终样张已逐页检查。修正单价精度、大写金额尾缀、签署区换行和印章排字;调整委外规格列后,30 行验收样本为 2 页,大小写合计保持在同一页。 +- Python 编译检查及 `git diff --check` 通过。新增 v2 到 v3 升级验证、旧审核入口保护、并发审批单次归档、PDF 生成失败回滚与单枚采购专用章检查。 +- 去掉章内审批员和日期后,两类订单的审批归档用例及手机浏览器流程再次通过;已检查页面预览和 PDF 章面,章外签署信息与审批记录保留。 +- 章面号码为 `3205070994140`,公司名按参考图改为细长仿宋字形,号码缩小并收紧排列。两类订单的审批归档用例及手机浏览器流程通过,页面与 PDF 样张已经渲染检查;图章放大图位于上述浏览器记录目录的 `seal-detail.png`。订单税号保持原值。 + +验证使用临时数据,没有对实际业务数据库执行升级。本轮交付为源码修改,尚未进行 EXE 重新打包或真实局域网多机验收。企业微信测试使用模拟发送,真实 Webhook、公司 Wi-Fi 和手机企业微信客户端仍须按上述接入步骤现场联调;复杂审批规则和第三方签章平台尚未接入。 diff --git a/项目检查-20260916.md b/项目检查-20260916.md new file mode 100644 index 0000000..8414d67 --- /dev/null +++ b/项目检查-20260916.md @@ -0,0 +1,144 @@ +# 采购订单生成与审批检查 + +> 本报告记录的是修复前提交 `4876d88`。随后已按建议修改本地 v2 源码;当前运行规则和升级步骤见 [运行与升级说明](运行与升级说明.md),验证结果见该文档末尾。 + +检查日期:2026-09-16。分支:`master`。远端和本地提交均为 `4876d8805d029702be92ec397bc5e9d6c9c25503`,最新提交时间为 2026-09-10 16:57 +0800。 + +目前是可以演示完整流程的初版。普通订单能生成、提交、两级审批并导出 PDF;正式多人使用前,仍需解决身份认证、数据权限、审批职责、金额一致性和事务恢复问题。 + +本次没有修改业务源码。使用 Flask test client 在 `tools/out/` 下复制代码、创建独立数据库进行验证,未向正式服务提交数据。审查脚本为 `tools/out/review_probe.py`,本次结果在 `tools/out/audit_geghi4ic/results.json`。这些测试文件被现有 `.gitignore` 排除。 + +## 已验证通过的基础能力 + +- 采购单 3 行、委外生产订单 30 行,均能完成草稿 → 待主管审核 → 待经理批准 → 已批准。 +- 主管和经理未提交盖章确认标记时,接口拒绝通过;普通制单员不能调用通过接口。 +- 审批动作会生成记录;已批准订单通过编辑接口修改会被拒绝。 +- 草稿 PDF 有水印,已批准 PDF 无水印;采购样本 1 页,委外样本 3 页,有重复明细表头、签署信息和图形印章。 +- 英文文件名的标准 XLSX 明细能导入。 + +这些结果只证明对应样本通过,不能作为所有边界情况、多人并发或正式签章有效性的验收结论。 + +## P0:正式使用前必须解决 + +### 1. 固定会话密钥可导致冒充管理员【已复现】 + +位置:`po_app/app.py:40`、`:52`。 + +会话签名密钥写在源码里。隔离测试使用该密钥签出的管理员会话,在未输入管理员密码的情况下成功访问 `/users`。仅修改默认密码不能解决这个问题。 + +修复方向:每个部署使用独立的随机密钥并外置保存,轮换现有密钥、使旧会话失效;上线初始化强制设置密码。 + +### 2. 订单权限只过滤了列表,详情和导出仍越权【已复现】 + +位置:`po_app/app.py:326`、`:435`、`:473`、`:1010`、`:1034`。 + +第二个制单员在列表看不到第一个制单员的订单,但直接访问订单 ID 能查看详情、导出 PDF、复制为自己的订单;导出台账还能得到其他人的订单。导出文件下载入口也仅检查是否登录。 + +修复方向:明确谁可看哪些订单,统一检查详情、编辑读取、复制、PDF、台账、统计、历史文件的权限。 + +### 3. 一个数据库错误会影响其他用户继续操作【已复现;并发风险为代码检查】 + +位置:`po_app/app.py:44`、`:966`、`:1071`。 + +所有请求共用一个数据库 Session。新增重名用户触发 `IntegrityError` 后没有回滚,另一个用户打开订单列表随即触发 `PendingRollbackError`。此时实际网页请求会报错,需要恢复会话或重启服务。服务还启用了多线程,共用 Session 带来的并发事务风险尚未经过压力测试。 + +修复方向:每请求独立 Session,结束时关闭,异常时回滚;对唯一性冲突提供明确提示。 + +## P1:直接影响制单与审批正确性 + +### 4. 审批职责和环节限制不完整【已复现】 + +位置:`po_app/app.py:89`、`:265`、`:360`、`:382`、`:403`。 + +- 主管账号可以新建、编辑、提交订单,再审核自己制作的订单。 +- 经理可以在主管审核前驳回;主管可以驳回已经进入经理批准环节的订单。 +- 管理员可执行全部环节是现有设计;如果公司要求制单、审核、批准分人,应显式限制并规定例外操作。 + +修复方向:服务端同时校验操作角色、当前环节、制单人和前一审批人;新建及复制权限也需一致。 + +### 5. 必填内容和明细数值未有效校验【已复现】 + +位置:`po_app/app.py:132`、`:297`、`:339`、`:488`。 + +提交只判断是否存在明细行。本次以下单据均走到“已批准”: + +| 输入 | 实际结果 | +|---|---| +| 供应商为空,数量 -2,单价 10 | 批准金额 -20 元 | +| 数量为 `abc` | 静默变成 0,批准金额 0 元 | +| 单价为 0 | 无异常提示,批准金额 0 元 | + +修复方向:按业务规则验证供应商、有效明细、数量、价格、日期及必要的结算信息;非法数字不得静默归零。零价是否允许应有明确业务规则。 + +### 6. 页面金额、保存金额与 PDF 单价不一致【已复现】 + +位置:`po_app/templates/order_edit.html:158`、`po_app/app.py:118`、`po_app/pdf_export.py:288`、`po_app/models.py:134`。 + +- 两行“数量 1、单价 0.334”:页面每行 0.33,页面合计 0.67;保存后合计 0.66。前端先累加原始乘积,后端先将每行金额保留两位。 +- “数量 3、单价 0.3333”:金额为 1.00,但 PDF 把单价显示为 0.33,读者按打印内容只能算出 0.99。订单详情也将单价限制为两位。 +- 金额与单价采用浮点数,舍入规则没有形成统一业务定义。 + +修复方向:统一定点金额、单价精度和舍入规则,前端、后端及导出使用相同口径,并明确展示价格精度。 + +### 7. 编辑后直接“提交审核”会提交旧数据【代码确认,未做浏览器点击测试】 + +位置:`po_app/templates/order_edit.html:7`、`:116`;`po_app/app.py:339`。 + +保存订单与提交审核是两个独立表单。已有明细的订单在页面修改价格、供应商等内容后,不先点保存而直接点“提交主管审核”,请求只携带提交说明,服务端提交的是数据库中的旧内容。首次填写尚未保存时则被当成明细为空。 + +修复方向:提供原子性的“保存并提交”,或检测未保存更改并阻止提交;跳转预览和导入时也应处理未保存内容。 + +### 8. 中文 Excel 文件名及日期类型处理有缺陷【已复现】 + +位置:`po_app/app.py:498`、`:527`、`:536`;`po_app/templates/order_edit.html:72`。 + +- 相同内容使用 `items.xlsx` 导入成功,改名为 `采购明细.xlsx` 导入失败。文件名清理后丢失可识别的扩展名,解析器选择错误。 +- XLSX 原生日期单元格被保存为 `2026-10-01 00:00:00`;编辑页的日期控件要求日期格式,两者不匹配,存在编辑保存时清空交期的风险。已验证数据库值;未用浏览器验证最终清空行为。 +- 供应商和物料导入使用相同文件名处理方式,存在同类问题,未逐个进行接口复测。 + +修复方向:服务器生成独立文件名并保留已验证的扩展名;统一规范日期格式,并为 XLS 日期序号单独处理。 + +### 9. 用户维护不完整,停用不能立即终止旧会话【部分已复现,部分代码确认】 + +位置:`po_app/templates/users.html:5`、`:22`;`po_app/app.py:52`、`:966`。 + +说明文档让用户在“用户”页改默认密码,但页面只有新增和列表,没有编辑、改密码、停用入口。后端存在按 ID 修改用户的逻辑,没有设置 active 的逻辑。 + +隔离测试将主管 active 改为 False 后,其已登录的会话仍能审核通过;请求时只获取用户,没有重新检查 active。 + +修复方向:补齐用户维护与改密页面,检查账号启用状态,支持强制失效会话,并约束角色和用户名输入。 + +### 10. 单号唯一性缺乏数据库和事务保证【约束缺失已复现,实际并发未测】 + +位置:`po_app/app.py:98`;`po_app/models.py:79`。 + +编号通过读取当天最大号再加一生成,数据库没有唯一约束。隔离测试成功插入两个相同单号。并发或重复请求可能产生重复单号,但本次没有做真实并发压测,不将其表述为已复现的线上并发故障。 + +修复方向:单号唯一索引、事务内分配或冲突重试,以及新建/复制请求的重复提交保护。 + +## 仍需补齐或按业务确认的流程 + +以下是代码和数据模型中尚未具备的能力,不代表公司已经要求全部实现: + +- **撤回、作废、批准后变更重审**:现有状态只有草稿、待审核、待批准、已批准、已驳回。尚无提交后主动撤回及正式订单作废/修订流程。 +- **审批版本追溯**:现有日志记录姓名、动作、时间、意见,但没有修改前后内容、订单版本、审批内容快照和稳定的操作人 ID。驳回修改后,无法从旧审批记录还原当时审核的明细。 +- **审批分派和提醒**:按角色统一进入待办;没有指定主管/经理、部门、金额阈值、代理人、催办或消息通知。若仅需固定两级流程,可后置复杂审批规则。 +- **正式文件归档**:批准后 PDF 按当前模板重新生成,没有冻结当时的模板和文件摘要。现有“章”是根据审批状态绘制的图形,`stamped=1` 是确认标记;没有独立的服务端盖章凭证或文件数字签名。是否需要签章服务须按使用场景确定。 +- **多人部署与恢复**:源码以开发服务器方式运行,数据库位于运行电脑本地。需要确定统一服务主机、启动方式、备份恢复和升级迁移;每台电脑各运行一个 EXE 会形成各自独立的数据库。README 已提到其他电脑通过主机 IP 访问,这个部署方式应落地验证。 +- **采购前置及后续流程**:请购、预算、询比价、附件、供应商回签、到货和付款跟踪均不在现有实现中。若目标只到订单生成与内部审批,不必把这些全部纳入首期。 + +## PDF 和测试的其他问题 + +- 已查看采购正式样本 1 页、委外正式样本 3 页、价格精度样本 1 页的渲染图。大写金额已经包含“元整”或“角/分”,模板右侧仍固定再打印“元整”,例如 `壹佰壹拾贰元伍角` 后又出现 `元整`。位置:`po_app/pdf_export.py:314`。 +- 委外版规格列较窄,短规格也发生多行折行;样本合计与条款/签署分到不同页。基本可读,需按实际单据样本确定版式要求。原始 Excel 套打模板未入库,因此未验证“完全复刻原模板”。 +- `tools/e2e_test.py:75` 和 `:78` 没有传目前必需的盖章标记,也未断言审批后的状态,随后直接打印“审核流程 ok”。该脚本可误报成功;部分工具仍硬编码原开发电脑路径。仓库另有盖章专项脚本,不能据此推定所有测试都失效。 +- 测试范围不含真实浏览器全流程、EXE 打包运行、局域网部署、压力测试及真实采购数据验收。未将既有测试脚本全部执行,因为部分脚本会操作实际运行数据库和固定外部目录。 + +## 建议修复顺序与验收标准 + +1. 身份认证、统一数据权限、按请求管理事务:不能伪造登录或跨制单员读单,单个失败请求不影响其他用户。 +2. 输入校验、金额精度、保存并提交、Excel 导入、用户维护:页面、数据库和 PDF 金额一致,修改后的内容确实进入审批,中文文件和日期正常。 +3. 审批角色及环节约束、唯一单号、撤回/作废/版本记录:每次审批能对应明确的人、环节和单据版本。 +4. 修正 PDF 和自动化检查,在统一部署方式下用真实模板、常见订单和多账号并发完成验收。 + +完成前两步和核心审批约束后,再开展小范围试用;是否扩展预算、分部门流程和外部签章,单独按业务决定。