支持供应商简称维护和订单PDF下载命名

This commit is contained in:
reddaniel110
2026-09-17 10:52:36 +08:00
parent 62cd117379
commit a86f0b0d5b
12 changed files with 227 additions and 21 deletions
+1
View File
@@ -42,6 +42,7 @@ Windows 7 SP1 服务器使用单独的 [Win7 离线打包说明](packaging/win7/
## 主要能力 ## 主要能力
- 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。 - 采购单和委外单,供应商/物料库、中文 XLS/XLSX 导入。
- 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。
- 草稿可暂存,“保存并提交”保证当前修改一起进入审批。 - 草稿可暂存,“保存并提交”保证当前修改一起进入审批。
- 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。 - 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。
- 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。 - 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。
+2
View File
@@ -2,6 +2,8 @@
本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。 本包用于在你的 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`。 2026-09-17 排版更新:采购专用章直径增加 30%,固定在右侧需方签章空位;供方和需方签章区之间留出 20 毫米间隔。采购单与委外订单均已调整,示例见 `samples/`。本次 PDF 模板版本为 `po-v4`。
升级后新审批生成的正式 PDF 使用新排版。已审批归档的 PDF 保留原版;需要采用新版的订单,请通过系统的“变更重审”流程重新审批。使用本包重新解压、安装环境并生成 EXE 后,按下文备份和替换服务程序。 升级后新审批生成的正式 PDF 使用新排版。已审批归档的 PDF 保留原版;需要采用新版的订单,请通过系统的“变更重审”流程重新审批。使用本包重新解压、安装环境并生成 EXE 后,按下文备份和替换服务程序。
+17 -6
View File
@@ -147,6 +147,11 @@ def suppliers():
setattr(s, f, request.form.get(f, "").strip()) setattr(s, f, request.form.get(f, "").strip())
if not s.name: if not s.name:
raise ValueError('供应商名称必填') 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: if not sid:
db.add(s) db.add(s)
db.commit() db.commit()
@@ -208,12 +213,16 @@ def supplier_import_excel():
if not any(r): if not any(r):
continue continue
(code, name, contact, phone, payment_terms, tax_no, (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) code, name = s(code), s(name)
if not name: if not name:
failed += 1 failed += 1
errs.append(u"第%d行:供应商名称为空" % i) errs.append(u"第%d行:供应商名称为空" % i)
continue 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 obj = db.query(Supplier).filter_by(code=code).first() if code else None
if not obj: if not obj:
obj = db.query(Supplier).filter_by(name=name).first() obj = db.query(Supplier).filter_by(name=name).first()
@@ -229,13 +238,15 @@ def supplier_import_excel():
obj.tax_no = s(tax_no) obj.tax_no = s(tax_no)
obj.bank_name = s(bank_name) obj.bank_name = s(bank_name)
obj.bank_account = s(bank_account) obj.bank_account = s(bank_account)
if short_name is not None:
obj.short_name = s(short_name)
obj.active = True obj.active = True
updated += 1 updated += 1
else: else:
db.add(Supplier(code=code, name=name, contact=s(contact), db.add(Supplier(code=code, name=name, contact=s(contact),
phone=s(phone), payment_terms=s(payment_terms), phone=s(phone), payment_terms=s(payment_terms),
tax_no=s(tax_no), bank_name=s(bank_name), 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 added += 1
db.commit() db.commit()
msg = u"导入完成:新增 %d,更新 %d,跳过 %d,失败 %d" % (added, updated, skipped, failed) msg = u"导入完成:新增 %d,更新 %d,跳过 %d,失败 %d" % (added, updated, skipped, failed)
@@ -254,11 +265,11 @@ def supplier_import_template():
wb = openpyxl.Workbook() wb = openpyxl.Workbook()
ws = wb.active ws = wb.active
ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号", ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号",
u"开户银行", u"银行帐号"]) u"开户银行", u"银行帐号", u"公司简称"])
ws.append(["S003", u"苏州精锐五金制品有限公司", u"周经理", "13800000001", ws.append(["S003", u"苏州精锐五金制品有限公司", u"周经理", "13800000001",
u"月结30天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行", u"月结30天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行",
"4562 1088 0012 3456"]) "4562 1088 0012 3456", u"精锐五金"])
for col, w in zip("ABCDEFGH", (10, 30, 12, 16, 14, 22, 26, 24)): for col, w in zip("ABCDEFGHI", (10, 30, 12, 16, 14, 22, 26, 24, 18)):
ws.column_dimensions[col].width = w ws.column_dimensions[col].width = w
buf = io.BytesIO() buf = io.BytesIO()
wb.save(buf) wb.save(buf)
@@ -406,7 +417,7 @@ def api_materials():
@login_required @login_required
def api_suppliers(): def api_suppliers():
rows = db.query(Supplier).filter_by(active=True).all() 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, "phone": s.phone, "tax_no": s.tax_no, "bank_name": s.bank_name,
"bank_account": s.bank_account, "bank_account": s.bank_account,
"payment_terms": s.payment_terms} for s in rows]) "payment_terms": s.payment_terms} for s in rows])
+6 -5
View File
@@ -66,7 +66,7 @@ def read_rows(path):
return out return out
def read_table(path, keys, order): def read_table(path, keys, order, missing=""):
"""通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。 """通用表格读取:按关键词映射表头,返回按 order 排列的二维列表。
与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可), 与 read_rows() 的区别是表头判定更宽松(命中任意关键词即可),
@@ -110,7 +110,7 @@ def read_table(path, keys, order):
vals = [] vals = []
for k in order: for k in order:
i = mapping.get(k) 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) out.append(vals)
return out return out
@@ -124,6 +124,7 @@ MATERIAL_KEYS = {
MATERIAL_ORDER = ["code", "spec", "unit", "price"] MATERIAL_ORDER = ["code", "spec", "unit", "price"]
SUPPLIER_KEYS = { SUPPLIER_KEYS = {
"short_name": [u"简称", u"别名"],
"code": [u"编号", u"供应商编号", u"代码"], "code": [u"编号", u"供应商编号", u"代码"],
"name": [u"名称", u"供应商名称", u"公司"], "name": [u"名称", u"供应商名称", u"公司"],
"contact": [u"联系人"], "contact": [u"联系人"],
@@ -134,7 +135,7 @@ SUPPLIER_KEYS = {
"bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"], "bank_account": [u"银行帐号", u"银行账号", u"帐号", u"账号"],
} }
SUPPLIER_ORDER = ["code", "name", "contact", "phone", "payment_terms", 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): def read_material_rows(path):
@@ -143,5 +144,5 @@ def read_material_rows(path):
def read_supplier_rows(path): def read_supplier_rows(path):
"""读取供应商表,返回 [编号,名称,联系人,电话,付款方式,税号,开户银行,银行帐号]。""" """简称追加在末列;旧表缺少该列时以 None 表示,更新时保留已有简称。"""
return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER) return read_table(path, SUPPLIER_KEYS, SUPPLIER_ORDER, missing=None)
+8 -3
View File
@@ -14,7 +14,7 @@ def upgrade_database(path):
if 'orders' not in tables: if 'orders' not in tables:
return return
version = con.execute('PRAGMA user_version').fetchone()[0] version = con.execute('PRAGMA user_version').fetchone()[0]
if version >= 4: if version >= 5:
return return
duplicates = con.execute('SELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) > 1').fetchall() duplicates = con.execute('SELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) > 1').fetchall()
if duplicates: if duplicates:
@@ -28,8 +28,13 @@ def upgrade_database(path):
upgrade_fixed_amounts(con) upgrade_fixed_amounts(con)
if version < 3: if version < 3:
merge_approval(con, tables) merge_approval(con, tables)
add_tracking(con) if version < 4:
con.execute('PRAGMA user_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() con.commit()
except Exception: except Exception:
con.rollback() con.rollback()
+2 -1
View File
@@ -81,6 +81,7 @@ class Supplier(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
code = Column(String(50)) code = Column(String(50))
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
short_name = Column(String(60), nullable=False, default='')
contact = Column(String(50)) contact = Column(String(50))
phone = Column(String(50)) phone = Column(String(50))
tax_no = Column(String(80)) tax_no = Column(String(80))
@@ -277,5 +278,5 @@ def init_db(db_path):
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
if not existed: if not existed:
with engine.connect() as con: 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) return engine, sessionmaker(bind=engine)
+29 -1
View File
@@ -1,6 +1,8 @@
import hashlib import hashlib
import io import io
import json import json
import re
import unicodedata
from datetime import datetime from datetime import datetime
from flask import abort, flash, redirect, render_template, request, send_file, url_for from flask import abort, flash, redirect, render_template, request, send_file, url_for
@@ -24,6 +26,32 @@ def pdf_bytes(order):
return buf.getvalue() 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): def register_order_routes(app):
@app.route('/orders/new', methods=['GET', 'POST']) @app.route('/orders/new', methods=['GET', 'POST'])
@login_required @login_required
@@ -254,7 +282,7 @@ def register_order_routes(app):
else: else:
data = pdf_bytes(order) data = pdf_bytes(order)
response = send_file(io.BytesIO(data), mimetype='application/pdf', as_attachment=request.args.get('dl') == '1', 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' response.headers['Cache-Control'] = 'private, no-store'
return response return response
+6 -3
View File
@@ -8,6 +8,7 @@
<div class="form-grid"> <div class="form-grid">
<div><label>编号</label><input type="text" name="code" placeholder="S003"></div> <div><label>编号</label><input type="text" name="code" placeholder="S003"></div>
<div><label>名称 *</label><input type="text" name="name" required></div> <div><label>名称 *</label><input type="text" name="name" required></div>
<div><label>公司简称(用于 PDF 文件名)</label><input type="text" name="short_name" maxlength="60" placeholder="如:精锐五金;留空使用全名"></div>
<div><label>联系人</label><input type="text" name="contact"></div> <div><label>联系人</label><input type="text" name="contact"></div>
<div><label>电话</label><input type="text" name="phone"></div> <div><label>电话</label><input type="text" name="phone"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" placeholder="月结30天"></div> <div><label>付款方式</label><input type="text" name="payment_terms" placeholder="月结30天"></div>
@@ -28,13 +29,14 @@
<tr><th>编号</th><th>名称</th><th>联系人</th><th>电话</th><th>付款方式</th><th>税号</th><th>开户银行</th><th>银行帐号</th><th>操作</th></tr> <tr><th>编号</th><th>名称</th><th>联系人</th><th>电话</th><th>付款方式</th><th>税号</th><th>开户银行</th><th>银行帐号</th><th>操作</th></tr>
{% for s in rows %} {% for s in rows %}
<tr class="{% if not s.active %}row-off{% endif %}"> <tr class="{% if not s.active %}row-off{% endif %}">
<td>{{ s.code }}</td><td>{{ s.name }}</td><td>{{ s.contact }}</td><td>{{ s.phone }}</td> <td>{{ s.code }}</td><td>{{ s.name }}{% if s.short_name %}<div class="hint">简称:{{ s.short_name }}</div>{% endif %}</td><td>{{ s.contact }}</td><td>{{ s.phone }}</td>
<td>{{ s.payment_terms }}</td><td>{{ s.tax_no }}</td><td>{{ s.bank_name }}</td> <td>{{ s.payment_terms }}</td><td>{{ s.tax_no }}</td><td>{{ s.bank_name }}</td>
<td>{{ s.bank_account }}</td> <td>{{ s.bank_account }}</td>
<td class="nowrap"> <td class="nowrap">
<button type="button" class="btn sm" onclick="openEdit(this)" <button type="button" class="btn sm" onclick="openEdit(this)"
data-id="{{ s.id }}" data-code="{{ s.code or '' }}" data-id="{{ s.id }}" data-code="{{ s.code or '' }}"
data-name="{{ s.name or '' }}" data-contact="{{ s.contact or '' }}" data-name="{{ s.name or '' }}" data-contact="{{ s.contact or '' }}"
data-short_name="{{ s.short_name or '' }}"
data-phone="{{ s.phone or '' }}" data-phone="{{ s.phone or '' }}"
data-payment_terms="{{ s.payment_terms or '' }}" data-payment_terms="{{ s.payment_terms or '' }}"
data-tax_no="{{ s.tax_no or '' }}" data-tax_no="{{ s.tax_no or '' }}"
@@ -69,7 +71,7 @@
<form method="post" action="{{ url_for('supplier_import_excel') }}" enctype="multipart/form-data"> <form method="post" action="{{ url_for('supplier_import_excel') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body"> <div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。</div> <div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。公司简称用于 PDF 文件名;旧表没有简称列时保留原简称,简称列留空则清除原简称。</div>
<div class="field"> <div class="field">
<label>1. 下载模板</label> <label>1. 下载模板</label>
<a href="{{ url_for('supplier_import_template') }}">供应商导入模板.xlsx</a> <a href="{{ url_for('supplier_import_template') }}">供应商导入模板.xlsx</a>
@@ -107,6 +109,7 @@
<div class="form-grid two"> <div class="form-grid two">
<div><label>编号</label><input type="text" name="code" id="edit_code"></div> <div><label>编号</label><input type="text" name="code" id="edit_code"></div>
<div><label>名称 *</label><input type="text" name="name" id="edit_name" required></div> <div><label>名称 *</label><input type="text" name="name" id="edit_name" required></div>
<div><label>公司简称(用于 PDF 文件名)</label><input type="text" name="short_name" id="edit_short_name" maxlength="60" placeholder="留空使用全名"></div>
<div><label>联系人</label><input type="text" name="contact" id="edit_contact"></div> <div><label>联系人</label><input type="text" name="contact" id="edit_contact"></div>
<div><label>电话</label><input type="text" name="phone" id="edit_phone"></div> <div><label>电话</label><input type="text" name="phone" id="edit_phone"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" id="edit_payment_terms"></div> <div><label>付款方式</label><input type="text" name="payment_terms" id="edit_payment_terms"></div>
@@ -126,7 +129,7 @@
<script> <script>
function openEdit(btn){ function openEdit(btn){
const d = btn.dataset; const d = btn.dataset;
['id','code','name','contact','phone','payment_terms','tax_no','bank_name','bank_account'] ['id','code','name','short_name','contact','phone','payment_terms','tax_no','bank_name','bank_account']
.forEach(k => { .forEach(k => {
const el = document.getElementById('edit_' + k); const el = document.getElementById('edit_' + k);
if (el) el.value = d[k] || ''; if (el) el.value = d[k] || '';
+39 -1
View File
@@ -7,6 +7,44 @@ import pytest
SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql' SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql'
def test_v4_supplier_alias_upgrade_preserves_orders_and_creates_one_backup(tmp_path):
from models import Order, OrderVersion, Supplier, init_db
path = tmp_path / 'po.db'
engine, factory = init_db(str(path))
archived = b'%PDF-1.4 immutable before alias update'
with factory() as db:
db.add(Supplier(name='旧供应商', code='S01', contact='原联系人'))
db.add(Order(id=1, order_no='V4-ORDER', status='approved', revision=2,
payment_status='partial', invoice_status='received', delivery_status='delivered'))
db.flush()
db.add(OrderVersion(order_id=1, revision=2, content_json='{}', pdf_data=archived,
pdf_sha256='existing-digest', template_version='po-v4'))
db.commit()
engine.dispose()
with sqlite3.connect(path) as con:
con.execute('ALTER TABLE suppliers DROP COLUMN short_name')
con.execute('PRAGMA user_version=4')
old_orders = con.execute('SELECT * FROM orders').fetchall()
old_archives = con.execute('SELECT * FROM order_versions').fetchall()
engine, factory = init_db(str(path))
with factory() as db:
supplier = db.query(Supplier).one()
assert (supplier.name, supplier.code, supplier.contact, supplier.short_name) == ('旧供应商', 'S01', '原联系人', '')
engine.dispose()
with sqlite3.connect(path) as con:
assert con.execute('PRAGMA user_version').fetchone()[0] == 5
assert con.execute('SELECT * FROM orders').fetchall() == old_orders
assert con.execute('SELECT * FROM order_versions').fetchall() == old_archives
backups = list(tmp_path.glob('*.bak'))
assert len(backups) == 1
with sqlite3.connect(backups[0]) as con:
assert con.execute('PRAGMA user_version').fetchone()[0] == 4
assert 'short_name' not in {row[1] for row in con.execute('PRAGMA table_info(suppliers)')}
engine, _ = init_db(str(path))
engine.dispose()
assert len(list(tmp_path.glob('*.bak'))) == 1
def test_v3_tracking_upgrade_preserves_existing_state_and_archives(tmp_path): def test_v3_tracking_upgrade_preserves_existing_state_and_archives(tmp_path):
import hashlib import hashlib
from models import Order, OrderVersion, User, init_db from models import Order, OrderVersion, User, init_db
@@ -48,7 +86,7 @@ def test_v3_tracking_upgrade_preserves_existing_state_and_archives(tmp_path):
assert con.execute('PRAGMA user_version').fetchone()[0] == 3 assert con.execute('PRAGMA user_version').fetchone()[0] == 3
assert 'payment_status' not in {row[1] for row in con.execute('PRAGMA table_info(orders)')} assert 'payment_status' not in {row[1] for row in con.execute('PRAGMA table_info(orders)')}
with sqlite3.connect(path) as con: with sqlite3.connect(path) as con:
assert con.execute('PRAGMA user_version').fetchone()[0] == 4 assert con.execute('PRAGMA user_version').fetchone()[0] == 5
assert con.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' assert con.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
engine, _ = init_db(str(path)) engine, _ = init_db(str(path))
engine.dispose() engine.dispose()
+98
View File
@@ -0,0 +1,98 @@
import io
import re
import openpyxl
import pytest
from werkzeug.http import parse_options_header
from conftest import post
from test_workflow import action, new, order, save
def download_name(response):
return parse_options_header(response.headers['Content-Disposition'])[1]['filename']
@pytest.mark.parametrize('kind,title', [('purchase', '采购单'), ('outsourcing', '委外生产订单')])
def test_supplier_alias_updates_filename_without_changing_archive(app, clients, kind, title):
company = '苏州精锐五金制品有限公司'
assert post(clients['maker'], '/suppliers', {'name': company, 'short_name': '精锐五金'}).status_code == 302
with app.Session() as db:
sid = db.query(app.Supplier).one().id
oid = new(app, clients['maker'], kind)
assert save(app, clients['maker'], oid, supplier_name=company, intent='submit').status_code == 302
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
with app.Session() as db:
number = db.get(app.Order, oid).order_no
path = '/orders/%s/pdf' % oid
archive = clients['maker'].get(path).data
original_version = order(app, oid)['version']
for suffix, disposition in [('', 'inline'), ('?dl=1', 'attachment')]:
response = clients['maker'].get(path + suffix)
assert response.headers['Content-Disposition'].startswith(disposition + ';')
assert download_name(response) == '%s_%s_精锐五金.pdf' % (title, number)
assert response.data == archive
assert post(clients['maker'], '/suppliers', {'id': sid, 'name': company, 'short_name': '精锐'}).status_code == 302
response = clients['maker'].get(path + '?dl=1')
assert download_name(response) == '%s_%s_精锐.pdf' % (title, number)
assert response.data == archive and order(app, oid)['version'] == original_version
assert clients['other'].get(path + '?dl=1').status_code == 404
# A cleared alias or removed master-data entry still yields an identifiable name.
assert post(clients['maker'], '/suppliers', {'id': sid, 'name': company, 'short_name': ''}).status_code == 302
assert download_name(clients['maker'].get(path)) == '%s_%s_%s.pdf' % (title, number, company)
assert post(clients['admin'], '/suppliers/%s/delete' % sid).status_code == 302
response = clients['maker'].get(path)
assert download_name(response) == '%s_%s_%s.pdf' % (title, number, company)
assert response.data == archive
def test_pdf_filename_handles_unsafe_characters_and_ambiguous_suppliers(app, clients):
company = '测试同名公司'
with app.Session() as db:
db.add(app.Supplier(name=company, tax_no='ONE', short_name='精锐/五金:分部?\r\n<二>'))
db.add(app.Supplier(name=company, tax_no='TWO', short_name='另一家'))
db.commit()
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid, supplier_name=company, supplier_tax_no='ONE').status_code == 302
response = clients['maker'].get('/orders/%s/pdf?dl=1' % oid)
filename = download_name(response)
assert response.status_code == 200
assert '精锐_五金' in filename and '分部' in filename
assert not re.search(r'[<>:"/\\|?*\x00-\x1f]', filename)
assert len(filename.encode('utf-16-le')) // 2 <= 255
# Without identifying tax information, do not pick an arbitrary company's alias.
assert save(app, clients['maker'], oid, supplier_name=company, supplier_tax_no='').status_code == 302
assert download_name(clients['maker'].get('/orders/%s/pdf' % oid)).endswith('_测试同名公司.pdf')
assert save(app, clients['maker'], oid, supplier_name='公司' * 90).status_code == 302
assert len(download_name(clients['maker'].get('/orders/%s/pdf' % oid)).encode('utf-16-le')) // 2 <= 255
def test_supplier_import_alias_headers_legacy_preservation_and_blank_clear(app, clients):
company = '苏州测试有限公司'
def upload(heads, row):
workbook = openpyxl.Workbook()
workbook.active.append(heads)
workbook.active.append(row)
stream = io.BytesIO()
workbook.save(stream)
stream.seek(0)
response = post(clients['maker'], '/suppliers/import_excel', {'file': (stream, '供应商.xlsx')})
assert response.status_code == 302
upload(['公司简称', '供应商名称', '供应商编号'], ['苏测', company, 'S001'])
with app.Session() as db:
supplier = db.query(app.Supplier).one()
assert (supplier.code, supplier.name, supplier.short_name) == ('S001', company, '苏测')
upload(['编号', '名称', '联系人'], ['S001', company, '新联系人'])
with app.Session() as db:
supplier = db.query(app.Supplier).one()
assert (supplier.short_name, supplier.contact) == ('苏测', '新联系人')
upload(['编号', '名称', '公司简称'], ['S001', company, ''])
with app.Session() as db:
assert db.query(app.Supplier).one().short_name == ''
template = openpyxl.load_workbook(io.BytesIO(clients['maker'].get('/suppliers/import_template.xlsx').data))
assert template.active.cell(1, 9).value == '公司简称'
assert template.active.cell(2, 9).value == '精锐五金'
template.close()
assert post(clients['maker'], '/suppliers', {'name': '超长简称测试', 'short_name': '长' * 61}).status_code == 400
+16 -1
View File
@@ -70,6 +70,18 @@ def run(executable=None):
admin.get_by_role('button',name='保存用户').click() admin.get_by_role('button',name='保存用户').click()
admin.wait_for_url(base + '/users') admin.wait_for_url(base + '/users')
admin.screenshot(path=str(RUN / 'users.png'),full_page=True) admin.screenshot(path=str(RUN / 'users.png'),full_page=True)
admin.goto(base + '/suppliers')
supplier_form = admin.locator('.card').first.locator('form')
supplier_form.locator('[name=name]').fill('测试供应商')
supplier_form.locator('[name=short_name]').fill('测试简称')
supplier_form.get_by_role('button', name='新增', exact=True).click()
admin.wait_for_url(base + '/suppliers')
admin.get_by_role('button', name='编辑', exact=True).click()
assert admin.locator('#edit_short_name').input_value() == '测试简称'
admin.locator('#edit_short_name').fill('测试供应商简称')
admin.locator('#editModal').get_by_role('button', name='保存', exact=True).click()
admin.wait_for_url(base + '/suppliers')
admin.screenshot(path=str(RUN / 'supplier-short-name.png'), full_page=True)
def sign_in(username, destination): def sign_in(username, destination):
tab=page(mobile=username != 'maker'); tab.goto(destination) tab=page(mobile=username != 'maker'); tab.goto(destination)
assert '/login' in tab.url assert '/login' in tab.url
@@ -148,6 +160,9 @@ def run(executable=None):
approver.screenshot(path=str(RUN / (kind+'-approved.png')),full_page=True) approver.screenshot(path=str(RUN / (kind+'-approved.png')),full_page=True)
response=approver.request.get(view_url.replace('/view','/pdf')) response=approver.request.get(view_url.replace('/view','/pdf'))
assert response.status==200 assert response.status==200
from werkzeug.http import parse_options_header
filename = parse_options_header(response.headers['content-disposition'])[1]['filename']
assert filename.endswith('_测试供应商简称.pdf')
data=response.body();(RUN/(kind+'.pdf')).write_bytes(data) data=response.body();(RUN/(kind+'.pdf')).write_bytes(data)
archives.append((view_url.replace('/view','/pdf'), hashlib.sha256(data).hexdigest())) archives.append((view_url.replace('/view','/pdf'), hashlib.sha256(data).hexdigest()))
document=pymupdf.open(stream=data,filetype='pdf') document=pymupdf.open(stream=data,filetype='pdf')
@@ -228,7 +243,7 @@ def run(executable=None):
assert hashlib.sha256(response.body()).hexdigest() == expected assert hashlib.sha256(response.body()).hexdigest() == expected
with sqlite3.connect(RUN / 'instance' / 'po.db') as db: with sqlite3.connect(RUN / 'instance' / 'po.db') as db:
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('PRAGMA user_version').fetchone()[0] == 4 assert db.execute('PRAGMA user_version').fetchone()[0] == 5
assert db.execute('SELECT count(*) FROM orders WHERE status = ?', ('approved',)).fetchone()[0] == 2 assert db.execute('SELECT count(*) FROM orders WHERE status = ?', ('approved',)).fetchone()[0] == 2
assert db.execute('SELECT count(*) FROM notifications').fetchone()[0] == 0 assert db.execute('SELECT count(*) FROM notifications').fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM orders WHERE payment_status='paid' AND delivery_status='pending'").fetchone()[0] == 1 assert db.execute("SELECT count(*) FROM orders WHERE payment_status='paid' AND delivery_status='pending'").fetchone()[0] == 1
+3
View File
@@ -10,6 +10,9 @@
4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。 4. 在运行系统的电脑上读取 `po_app/instance/setup.token`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。
5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。 5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。
6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。 6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。
- 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。
- 简称只用于下载命名,PDF 正文仍显示全名;已审批订单重新下载即可使用新简称,归档内容保持不变。文件名中的 Windows 不允许字符会被替换。
- Excel 供应商模板末列支持“公司简称”。旧表没有该列时保留原简称;该列留空则清除原简称。此功能使用数据库结构版本 5,旧数据库启动时先备份再增量升级。
系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。 系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。