支持供应商简称维护和订单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 导入。
- 供应商支持维护公司简称,PDF 下载文件名采用“单据类型_订单号_供应商简称.pdf”;未填写简称时使用供应商全名,已审批文件重新下载也适用。
- 草稿可暂存,“保存并提交”保证当前修改一起进入审批。
- 数量、单价四位精度;逐行四舍五入到分再合计,页面、数据库和 PDF 使用同一口径。
- 制单与审批由不同账号完成,管理员也遵守分工;审批员一次确认即可通过或驳回。
+2
View File
@@ -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 后,按下文备份和替换服务程序。
+17 -6
View File
@@ -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])
+6 -5
View File
@@ -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)
+8 -3
View File
@@ -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()
+2 -1
View File
@@ -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)
+29 -1
View File
@@ -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
+6 -3
View File
@@ -8,6 +8,7 @@
<div class="form-grid">
<div><label>编号</label><input type="text" name="code" placeholder="S003"></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="phone"></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>
{% for s in rows %}
<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.bank_account }}</td>
<td class="nowrap">
<button type="button" class="btn sm" onclick="openEdit(this)"
data-id="{{ s.id }}" data-code="{{ s.code or '' }}"
data-name="{{ s.name or '' }}" data-contact="{{ s.contact or '' }}"
data-short_name="{{ s.short_name or '' }}"
data-phone="{{ s.phone or '' }}"
data-payment_terms="{{ s.payment_terms 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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。</div>
<div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。公司简称用于 PDF 文件名;旧表没有简称列时保留原简称,简称列留空则清除原简称。</div>
<div class="field">
<label>1. 下载模板</label>
<a href="{{ url_for('supplier_import_template') }}">供应商导入模板.xlsx</a>
@@ -107,6 +109,7 @@
<div class="form-grid two">
<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>公司简称(用于 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="phone" id="edit_phone"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" id="edit_payment_terms"></div>
@@ -126,7 +129,7 @@
<script>
function openEdit(btn){
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 => {
const el = document.getElementById('edit_' + 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'
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):
import hashlib
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 'payment_status' not in {row[1] for row in con.execute('PRAGMA table_info(orders)')}
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'
engine, _ = init_db(str(path))
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.wait_for_url(base + '/users')
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):
tab=page(mobile=username != 'maker'); tab.goto(destination)
assert '/login' in tab.url
@@ -148,6 +160,9 @@ def run(executable=None):
approver.screenshot(path=str(RUN / (kind+'-approved.png')),full_page=True)
response=approver.request.get(view_url.replace('/view','/pdf'))
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)
archives.append((view_url.replace('/view','/pdf'), hashlib.sha256(data).hexdigest()))
document=pymupdf.open(stream=data,filetype='pdf')
@@ -228,7 +243,7 @@ def run(executable=None):
assert hashlib.sha256(response.body()).hexdigest() == expected
with sqlite3.connect(RUN / 'instance' / 'po.db') as db:
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 notifications').fetchone()[0] == 0
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`,把初始化口令填入页面,设置管理员账号和密码。不要发送该文件给无关人员。
5. 管理员在“用户”页新增制单员、审批员,两个环节分配给不同人员。新增账号首次登录必须修改初始密码。
6. 录入真实供应商和物料,再开始制单。新数据库不再预置演示账号、演示供应商或假银行信息。
- 在“供应商管理”填写“公司简称”,用于 PDF 导出文件名,例如 `采购单_PO202609170001_精锐五金.pdf`。未填简称时使用订单上的供应商全名;按全名匹配供应商资料,同名有歧义时结合税号匹配,仍有歧义则使用全名。
- 简称只用于下载命名,PDF 正文仍显示全名;已审批订单重新下载即可使用新简称,归档内容保持不变。文件名中的 Windows 不允许字符会被替换。
- Excel 供应商模板末列支持“公司简称”。旧表没有该列时保留原简称;该列留空则清除原简称。此功能使用数据库结构版本 5,旧数据库启动时先备份再增量升级。
系统使用 Waitress 提供服务。源码和 EXE 均支持 `PO_DATA_DIR` 指定数据目录;未设置时分别使用 `po_app` 目录和 EXE 所在目录。`PO_HOST` 默认 `0.0.0.0`;端口已占用时启动程序会在 8120 之前尝试后续端口,以控制台打印地址为准。