From a86f0b0d5bb64ace2672b44603030851379d361a Mon Sep 17 00:00:00 2001 From: reddaniel110 Date: Thu, 17 Sep 2026 10:52:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E7=AE=80=E7=A7=B0=E7=BB=B4=E6=8A=A4=E5=92=8C=E8=AE=A2=E5=8D=95?= =?UTF-8?q?PDF=E4=B8=8B=E8=BD=BD=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + packaging/win7/README_WIN7.md | 2 + po_app/app.py | 23 ++++++-- po_app/excel_io.py | 11 ++-- po_app/migrations.py | 11 +++- po_app/models.py | 3 +- po_app/order_routes.py | 30 +++++++++- po_app/templates/suppliers.html | 9 ++- tests/test_migration.py | 40 +++++++++++++- tests/test_pdf_filenames.py | 98 +++++++++++++++++++++++++++++++++ tools/browser_smoke.py | 17 +++++- 运行与升级说明.md | 3 + 12 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 tests/test_pdf_filenames.py diff --git a/README.md b/README.md index c684691..14adfbb 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/ ## 主要能力 - 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。 +- 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。 - 草稿可暂存,“保存并提交”保证当前修改一起进入审批。 - 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。 - 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。 diff --git a/packaging/win7/README_WIN7.md b/packaging/win7/README_WIN7.md index ebdd4de..f70b383 100644 --- a/packaging/win7/README_WIN7.md +++ b/packaging/win7/README_WIN7.md @@ -2,6 +2,8 @@ 本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。 +2026-09-17 文件名更新:在“供应商管理”的新增或编辑窗口填写“公司简称”,PDF 文件名即为“采购单_订单号_简称.pdf”(委外单使用对应类型名称);未填写简称时使用供应商全名。已审批订单重新下载也使用新文件名,归档 PDF 内容不变,无需重新审批。Excel 模板新增末列“公司简称”,旧表缺少该列时保留原简称。本次启动会先备份数据库,再升级到数据库结构版本 5,新增简称字段。 + 2026-09-17 排版更新:采购专用章直径增加 30%,固定在右侧需方签章空位;供方和需方签章区之间留出 20 毫米间隔。采购单与委外订单均已调整,示例见 `samples/`。本次 PDF 模板版本为 `po-v4`。 升级后新审批生成的正式 PDF 使用新排版。已审批归档的 PDF 保留原版;需要采用新版的订单,请通过系统的“变更重审”流程重新审批。使用本包重新解压、安装环境并生成 EXE 后,按下文备份和替换服务程序。 diff --git a/po_app/app.py b/po_app/app.py index 9ff16cb..d2ac177 100644 --- a/po_app/app.py +++ b/po_app/app.py @@ -147,6 +147,11 @@ def suppliers(): setattr(s, f, request.form.get(f, "").strip()) if not s.name: raise ValueError('供应商名称必填') + if 'short_name' in request.form: + short_name = request.form['short_name'].strip() + if len(short_name) > 60: + raise ValueError('供应商简称不能超过60个字符') + s.short_name = short_name if not sid: db.add(s) db.commit() @@ -208,12 +213,16 @@ def supplier_import_excel(): if not any(r): continue (code, name, contact, phone, payment_terms, tax_no, - bank_name, bank_account) = (list(r) + [""] * 8)[:8] + bank_name, bank_account, short_name) = (list(r) + [None] * 9)[:9] code, name = s(code), s(name) if not name: failed += 1 errs.append(u"第%d行:供应商名称为空" % i) continue + if short_name is not None and len(s(short_name)) > 60: + failed += 1 + errs.append(u"第%d行:供应商简称不能超过60个字符" % i) + continue obj = db.query(Supplier).filter_by(code=code).first() if code else None if not obj: obj = db.query(Supplier).filter_by(name=name).first() @@ -229,13 +238,15 @@ def supplier_import_excel(): obj.tax_no = s(tax_no) obj.bank_name = s(bank_name) obj.bank_account = s(bank_account) + if short_name is not None: + obj.short_name = s(short_name) obj.active = True updated += 1 else: db.add(Supplier(code=code, name=name, contact=s(contact), phone=s(phone), payment_terms=s(payment_terms), tax_no=s(tax_no), bank_name=s(bank_name), - bank_account=s(bank_account), active=True)) + bank_account=s(bank_account), short_name=s(short_name), active=True)) added += 1 db.commit() msg = u"导入完成:新增 %d,更新 %d,跳过 %d,失败 %d" % (added, updated, skipped, failed) @@ -254,11 +265,11 @@ def supplier_import_template(): wb = openpyxl.Workbook() ws = wb.active ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号", - u"开户银行", u"银行帐号"]) + u"开户银行", u"银行帐号", u"公司简称"]) ws.append(["S003", u"苏州精锐五金制品有限公司", u"周经理", "13800000001", u"月结30天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行", - "4562 1088 0012 3456"]) - for col, w in zip("ABCDEFGH", (10, 30, 12, 16, 14, 22, 26, 24)): + "4562 1088 0012 3456", u"精锐五金"]) + for col, w in zip("ABCDEFGHI", (10, 30, 12, 16, 14, 22, 26, 24, 18)): ws.column_dimensions[col].width = w buf = io.BytesIO() wb.save(buf) @@ -406,7 +417,7 @@ def api_materials(): @login_required def api_suppliers(): rows = db.query(Supplier).filter_by(active=True).all() - return jsonify([{"id": s.id, "name": s.name, "contact": s.contact, + return jsonify([{"id": s.id, "name": s.name, "short_name": s.short_name, "contact": s.contact, "phone": s.phone, "tax_no": s.tax_no, "bank_name": s.bank_name, "bank_account": s.bank_account, "payment_terms": s.payment_terms} for s in rows]) diff --git a/po_app/excel_io.py b/po_app/excel_io.py index 34a94c2..4ab423d 100644 --- a/po_app/excel_io.py +++ b/po_app/excel_io.py @@ -66,7 +66,7 @@ def read_rows(path): return out -def read_table(path, keys, order): +def read_table(path, keys, order, missing=""): """通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。 与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可), @@ -110,7 +110,7 @@ def read_table(path, keys, order): vals = [] for k in order: i = mapping.get(k) - vals.append(row[i] if (i is not None and i < len(row)) else "") + vals.append(row[i] if (i is not None and i < len(row)) else missing) out.append(vals) return out @@ -124,6 +124,7 @@ MATERIAL_KEYS = { MATERIAL_ORDER = ["code", "spec", "unit", "price"] SUPPLIER_KEYS = { + "short_name": [u"简称", u"别名"], "code": [u"编号", u"供应商编号", u"代码"], "name": [u"名称", u"供应商名称", u"公司"], "contact": [u"联系人"], @@ -134,7 +135,7 @@ SUPPLIER_KEYS = { "bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"], } SUPPLIER_ORDER = ["code", "name", "contact", "phone", "payment_terms", - "tax_no", "bank_name", "bank_account"] + "tax_no", "bank_name", "bank_account", "short_name"] def read_material_rows(path): @@ -143,5 +144,5 @@ def read_material_rows(path): def read_supplier_rows(path): - """读取供应商表,返回 [编号,名称,联系人,电话,付款方式,税号,开户银行,银行帐号]。""" - return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER) + """简称追加在末列;旧表缺少该列时以 None 表示,更新时保留已有简称。""" + return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER, missing=None) diff --git a/po_app/migrations.py b/po_app/migrations.py index 88cab30..71a8d0c 100644 --- a/po_app/migrations.py +++ b/po_app/migrations.py @@ -14,7 +14,7 @@ def upgrade_database(path): if 'orders' not in tables: return version = con.execute('PRAGMA user_version').fetchone()[0] - if version >= 4: + if version >= 5: return duplicates = con.execute('SELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) > 1').fetchall() if duplicates: @@ -28,8 +28,13 @@ def upgrade_database(path): upgrade_fixed_amounts(con) if version < 3: merge_approval(con, tables) - add_tracking(con) - con.execute('PRAGMA user_version=4') + if version < 4: + add_tracking(con) + if 'suppliers' in tables: + columns = {row[1] for row in con.execute('PRAGMA table_info(suppliers)')} + if 'short_name' not in columns: + con.execute("ALTER TABLE suppliers ADD COLUMN short_name VARCHAR(60) NOT NULL DEFAULT ''") + con.execute('PRAGMA user_version=5') con.commit() except Exception: con.rollback() diff --git a/po_app/models.py b/po_app/models.py index a93c156..0fd155d 100644 --- a/po_app/models.py +++ b/po_app/models.py @@ -81,6 +81,7 @@ class Supplier(Base): id = Column(Integer, primary_key=True) code = Column(String(50)) name = Column(String(200), nullable=False) + short_name = Column(String(60), nullable=False, default='') contact = Column(String(50)) phone = Column(String(50)) tax_no = Column(String(80)) @@ -277,5 +278,5 @@ def init_db(db_path): Base.metadata.create_all(engine) if not existed: with engine.connect() as con: - con.exec_driver_sql('PRAGMA user_version=4') + con.exec_driver_sql('PRAGMA user_version=5') return engine, sessionmaker(bind=engine) diff --git a/po_app/order_routes.py b/po_app/order_routes.py index cf18863..6ddbcfd 100644 --- a/po_app/order_routes.py +++ b/po_app/order_routes.py @@ -1,6 +1,8 @@ import hashlib import io import json +import re +import unicodedata from datetime import datetime from flask import abort, flash, redirect, render_template, request, send_file, url_for @@ -24,6 +26,32 @@ def pdf_bytes(order): return buf.getvalue() +def pdf_download_name(order): + # Use current master-data aliases without regenerating an approved PDF. + company = (order.supplier_name or '').strip() + matches = db.query(Supplier.short_name, Supplier.tax_no).filter(Supplier.name == company).all() + tax_no = (order.supplier_tax_no or '').strip() + if tax_no: + same_tax = [row for row in matches if (row.tax_no or '').strip() == tax_no] + if same_tax: + matches = same_tax + aliases = {(row.short_name or '').strip() or company for row in matches} + # Ambiguous duplicate names fall back to the company recorded on the order. + supplier = next(iter(aliases)) if len(aliases) == 1 else company + + def part(value, fallback, limit): + value = ''.join(c for c in str(value or '') if not unicodedata.category(c).startswith('C')) + value = re.sub(r'[<>:"/\\|?*]', '_', value) + value = re.sub(r'\s+', ' ', value).strip(' .') + # Windows counts UTF-16 units; retain Chinese and bound the full filename. + value = value.encode('utf-16-le')[:limit * 2].decode('utf-16-le', errors='ignore').rstrip(' .') + return value or fallback + + return '%s_%s_%s.pdf' % (part(order.doc_type_text, '订单', 32), + part(order.order_no, '未编号', 64), + part(supplier, '未填写供应商', 80)) + + def register_order_routes(app): @app.route('/orders/new', methods=['GET', 'POST']) @login_required @@ -254,7 +282,7 @@ def register_order_routes(app): 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)) + download_name=pdf_download_name(order)) response.headers['Cache-Control'] = 'private, no-store' return response diff --git a/po_app/templates/suppliers.html b/po_app/templates/suppliers.html index 92aaa78..df13be6 100644 --- a/po_app/templates/suppliers.html +++ b/po_app/templates/suppliers.html @@ -8,6 +8,7 @@
+
@@ -28,13 +29,14 @@ 编号名称联系人电话付款方式税号开户银行银行帐号操作 {% for s in rows %} - {{ s.code }}{{ s.name }}{{ s.contact }}{{ s.phone }} + {{ s.code }}{{ s.name }}{% if s.short_name %}
简称:{{ s.short_name }}
{% endif %}{{ s.contact }}{{ s.phone }} {{ s.payment_terms }}{{ s.tax_no }}{{ s.bank_name }} {{ s.bank_account }}