完善采购审批流程、企业微信通知和采购专用章

This commit is contained in:
reddaniel110
2026-09-16 11:45:21 +08:00
parent 4876d8805d
commit bf952196fa
58 changed files with 3451 additions and 1909 deletions
+64
View File
@@ -0,0 +1,64 @@
import importlib
import os
import sys
from pathlib import Path
import pytest
from werkzeug.security import generate_password_hash
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'po_app'))
PASSWORD = 'Test-only-Password-2026'
@pytest.fixture(scope='session')
def module(tmp_path_factory):
os.environ['PO_DATA_DIR'] = str(tmp_path_factory.mktemp('po-data'))
os.environ.pop('PO_SECRET_KEY', None)
result = importlib.import_module('app')
result.app.config['TESTING'] = True
yield result
result.engine.dispose()
@pytest.fixture
def app(module):
from models import Base
Base.metadata.drop_all(module.engine)
Base.metadata.create_all(module.engine)
hashed = generate_password_hash(PASSWORD)
with module.Session() as db:
for name, role in [('admin', 'admin'), ('maker', 'maker'), ('other', 'maker'),
('reviewer', 'approver'), ('approver', 'approver')]:
db.add(module.User(username=name, name=name, role=role, active=True,
session_version=1, must_change_password=False, password_hash=hashed))
db.commit()
return module
def post(client, path, data=None, **kwargs):
with client.session_transaction() as sess:
token = sess.get('csrf_token')
if not token:
client.get('/login')
with client.session_transaction() as sess:
token = sess['csrf_token']
if data is None:
data = {}
data = data.copy()
data['csrf_token'] = token
return client.post(path, data=data, **kwargs)
def login(app, name):
client = app.app.test_client()
client.get('/login')
response = post(client, '/login', {'username': name, 'password': PASSWORD})
assert response.status_code == 302
client.get(response.location)
return client
@pytest.fixture
def clients(app):
return {name: login(app, name) for name in ('admin', 'maker', 'other', 'reviewer', 'approver')}
+109
View File
@@ -0,0 +1,109 @@
CREATE TABLE materials (
id INTEGER NOT NULL,
code VARCHAR(80) NOT NULL,
spec VARCHAR(300),
unit VARCHAR(20),
price FLOAT,
active BOOLEAN,
PRIMARY KEY (id)
)
;
CREATE TABLE suppliers (
id INTEGER NOT NULL,
code VARCHAR(50),
name VARCHAR(200) NOT NULL,
contact VARCHAR(50),
phone VARCHAR(50),
tax_no VARCHAR(80),
bank_name VARCHAR(120),
bank_account VARCHAR(80),
payment_terms VARCHAR(120),
active BOOLEAN,
PRIMARY KEY (id)
)
;
CREATE TABLE users (
id INTEGER NOT NULL,
username VARCHAR(50) NOT NULL,
password_hash VARCHAR(200) NOT NULL,
name VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL,
active BOOLEAN,
created_at DATETIME,
PRIMARY KEY (id),
UNIQUE (username)
)
;
CREATE TABLE orders (
id INTEGER NOT NULL,
order_no VARCHAR(50) NOT NULL,
doc_type VARCHAR(20) NOT NULL,
supplier_name VARCHAR(200),
supplier_contact VARCHAR(50),
supplier_phone VARCHAR(50),
supplier_tax_no VARCHAR(80),
supplier_bank_name VARCHAR(120),
supplier_bank_account VARCHAR(80),
payment_terms VARCHAR(120),
our_company VARCHAR(200),
our_contact VARCHAR(50),
our_phone VARCHAR(50),
our_tax_no VARCHAR(80),
tax_rate VARCHAR(30),
order_date VARCHAR(20),
delivery_place VARCHAR(300),
note TEXT,
total_amount FLOAT,
status VARCHAR(20),
created_by INTEGER,
created_by_name VARCHAR(50),
created_at DATETIME,
submitted_at DATETIME,
reviewed_by VARCHAR(50),
reviewed_at DATETIME,
approved_by VARCHAR(50),
approved_at DATETIME,
reject_reason TEXT,
PRIMARY KEY (id),
FOREIGN KEY(created_by) REFERENCES users (id)
)
;
CREATE TABLE approval_logs (
id INTEGER NOT NULL,
order_id INTEGER,
action VARCHAR(50),
user_name VARCHAR(50),
comment TEXT,
created_at DATETIME,
PRIMARY KEY (id),
FOREIGN KEY(order_id) REFERENCES orders (id)
)
;
CREATE TABLE order_items (
id INTEGER NOT NULL,
order_id INTEGER,
seq INTEGER,
material_code VARCHAR(80),
material_spec VARCHAR(300),
unit VARCHAR(20),
qty FLOAT,
price FLOAT,
amount FLOAT,
delivery_date VARCHAR(30),
remark VARCHAR(200),
PRIMARY KEY (id),
FOREIGN KEY(order_id) REFERENCES orders (id)
)
;
+25
View File
@@ -0,0 +1,25 @@
import importlib.util
import sqlite3
from pathlib import Path
def test_backup_and_restore_snapshot(tmp_path):
source = Path(__file__).resolve().parents[1] / 'tools' / 'backup_db.py'
spec = importlib.util.spec_from_file_location('backup_tool', source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
instance = tmp_path / 'instance'
instance.mkdir()
path = instance / 'po.db'
with sqlite3.connect(path) as db:
db.execute('CREATE TABLE orders(id INTEGER PRIMARY KEY, amount INTEGER)')
db.execute('INSERT INTO orders(amount) VALUES(66)')
snapshot = module.backup_database(tmp_path)
with sqlite3.connect(path) as db:
db.execute('UPDATE orders SET amount=100')
with sqlite3.connect(snapshot) as db:
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('SELECT amount FROM orders').fetchone()[0] == 66
with sqlite3.connect(tmp_path / 'restored.db') as restored:
db.backup(restored)
assert restored.execute('SELECT amount FROM orders').fetchone()[0] == 66
+125
View File
@@ -0,0 +1,125 @@
import sqlite3
from decimal import Decimal
from pathlib import Path
import pytest
SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql'
def legacy(path, duplicate=False):
with sqlite3.connect(path) as con:
con.executescript(SCHEMA.read_text(encoding='utf-8'))
con.execute("INSERT INTO users(id,username,password_hash,name,role,active) VALUES(1,'owner','old-hash','Owner','maker',1)")
con.execute("INSERT INTO orders(id,order_no,doc_type,status,total_amount,created_by) VALUES(1,'PO202609100001','purchase','approved',1,1)")
con.execute("INSERT INTO order_items(order_id,seq,qty,price,amount) VALUES(1,1,3,0.3333,1)")
con.execute("INSERT INTO materials(code,price) VALUES('MAT',12.3456)")
con.execute("INSERT INTO approval_logs(order_id,action,user_name) VALUES(1,'approved','Previous Manager')")
if duplicate:
con.execute("INSERT INTO orders(order_no,doc_type) VALUES('PO202609100001','purchase')")
def test_legacy_upgrade_preserves_history_and_fixed_amounts(tmp_path):
from models import Material, Order, User, init_db
path = tmp_path / 'po.db'
legacy(path)
engine, factory = init_db(str(path))
with factory() as db:
order = db.get(Order,1)
assert order.status == 'approved' and order.order_no == 'PO202609100001'
assert order.total_amount == Decimal('1')
assert order.items[0].price == Decimal('0.3333')
assert order.logs[0].user_name == 'Previous Manager'
assert db.get(Material,1).price == Decimal('12.3456')
assert db.get(User,1).must_change_password
engine.dispose()
backups = list(tmp_path.glob('*.bak'))
assert len(backups) == 1
with sqlite3.connect(backups[0]) as con:
assert con.execute('SELECT price FROM order_items').fetchone()[0] == 0.3333
assert 'price_units' not in {x[1] for x in con.execute('PRAGMA table_info(order_items)')}
again,_ = init_db(str(path))
again.dispose()
assert len(list(tmp_path.glob('*.bak'))) == 1
def test_duplicates_stop_migration_without_renumbering(tmp_path):
from models import init_db
path=tmp_path/'po.db'
legacy(path,duplicate=True)
with pytest.raises(RuntimeError,match='重复单号'):
init_db(str(path))
with sqlite3.connect(path) as con:
assert con.execute('SELECT COUNT(*) FROM orders').fetchone()[0] == 2
assert 'row_version' not in {x[1] for x in con.execute('PRAGMA table_info(orders)')}
def test_invalid_amount_rolls_back_whole_migration(tmp_path):
from models import init_db
path=tmp_path/'po.db'
legacy(path)
with sqlite3.connect(path) as con:
con.execute("UPDATE order_items SET price='Infinity'")
with pytest.raises(RuntimeError,match='无法迁移'):
init_db(str(path))
with sqlite3.connect(path) as con:
assert 'row_version' not in {x[1] for x in con.execute('PRAGMA table_info(orders)')}
assert con.execute('SELECT price FROM order_items').fetchone()[0] == 'Infinity'
@pytest.mark.parametrize('manager_hook', ['', 'manager-hook'])
def test_v2_merge_preserves_archives_and_migrates_pending_work(tmp_path, manager_hook):
import hashlib
from datetime import datetime
from models import (ApprovalLog, Notification, NotificationSettings, Order,
OrderVersion, User, init_db)
path = tmp_path / 'po.db'
engine, factory = init_db(str(path))
old_pdf = b'%PDF-1.4 old archived two-stamp file'
digest = hashlib.sha256(old_pdf).hexdigest()
with factory() as db:
db.add(User(id=1, username='r', password_hash='hash', name='Old Reviewer', role='reviewer',
session_version=4, must_change_password=False))
db.add(NotificationSettings(id=1, enabled=True, base_url='http://192.168.1.2:8080',
review_webhook='review-hook', approval_webhook=manager_hook))
for oid, status in [(1, 'pending_review'), (2, 'pending_approval'), (3, 'approved')]:
db.add(Order(id=oid, order_no='LEGACY-%s' % oid, status=status, revision=1,
reviewed_by='Old Reviewer' if oid > 1 else None,
reviewed_at=datetime(2026, 9, 16) if oid > 1 else None))
db.flush()
db.add(ApprovalLog(order_id=2, action='主管审核通过', user_name='Old Reviewer', revision=1,
from_status='pending_review', to_status='pending_approval'))
db.add(OrderVersion(order_id=3, revision=1, content_json='{}', pdf_data=old_pdf,
pdf_sha256=digest, template_version='po-v2'))
for oid, channel, status in [(1, 'review', 'pending_review'), (2, 'review', 'pending_review'),
(2, 'approval', 'pending_approval'), (3, 'approval', 'pending_approval')]:
db.add(Notification(order_id=oid, revision=1, channel=channel, event='old', expected_status=status))
db.add(Notification(order_id=3, revision=1, channel='review', event='old', status='sent'))
db.commit()
with engine.begin() as con:
con.exec_driver_sql('PRAGMA user_version=2')
engine.dispose()
engine, factory = init_db(str(path))
with factory() as db:
user = db.get(User, 1)
assert (user.role, user.session_version, user.must_change_password) == ('approver', 5, False)
assert db.get(Order, 1).status == db.get(Order, 2).status == 'pending_approval'
assert db.get(Order, 1).row_version == db.get(Order, 2).row_version == 2
assert db.get(Order, 2).reviewed_by == 'Old Reviewer'
assert db.query(ApprovalLog).one().action == '主管审核通过'
archive = db.query(OrderVersion).one()
assert (archive.pdf_data, archive.pdf_sha256, archive.template_version) == (old_pdf, digest, 'po-v2')
rows = db.query(Notification).order_by(Notification.id).all()
assert [n.status for n in rows] == ['pending', 'cancelled', 'pending', 'cancelled', 'sent']
assert rows[0].channel == 'approval' and rows[0].expected_status == 'pending_approval'
assert rows[4].channel == 'review' # Historical delivery remains untouched.
config = db.get(NotificationSettings, 1)
assert config.approval_webhook == (manager_hook or 'review-hook') and config.review_webhook == ''
engine.dispose()
assert len(list(tmp_path.glob('*.bak'))) == 1
again, factory = init_db(str(path))
with factory() as db:
assert db.get(User, 1).session_version == 5
assert db.get(Order, 1).row_version == 2
again.dispose()
assert len(list(tmp_path.glob('*.bak'))) == 1
+7
View File
@@ -0,0 +1,7 @@
const assert = require('node:assert/strict');
const money = require('../po_app/static/money.js');
assert.equal(money.format(money.lineCents('1','0.334') + money.lineCents('1','0.334')), '0.66');
assert.equal(money.format(money.lineCents('1','2.675')), '2.68');
assert.equal(money.format(money.lineCents('3','0.3333')), '1.00');
for (const bad of ['-1','NaN','Infinity','abc','0.00001']) assert.throws(()=>money.lineCents(bad,'1'));
console.log('Decimal preview checks passed');
+238
View File
@@ -0,0 +1,238 @@
import json
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from urllib.error import HTTPError
from urllib.parse import parse_qs, urlsplit
import pytest
from sqlalchemy import text
from conftest import PASSWORD, post
from test_workflow import action, approve, form, new, order, pending, save
HOOK = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=TEST-ONLY-NOT-A-REAL-KEY'
BASE = 'http://192.168.1.10:8080'
def configure(clients, **overrides):
data = dict(enabled='1', base_url=BASE,
approval_webhook=HOOK + '-approval', result_webhook=HOOK + '-result')
data.update(overrides)
return post(clients['admin'], '/settings/notifications', data)
def notices(app):
from models import Notification
with app.Session() as db:
return [dict(id=n.id, status=n.status, channel=n.channel, attempts=n.attempts, error=n.last_error)
for n in db.query(Notification).order_by(Notification.id)]
def test_workflow_queues_only_committed_transitions(app, clients, monkeypatch):
import notifications
from models import Notification, NotificationSettings
monkeypatch.setattr(notifications, 'send_wecom', lambda *args: pytest.fail('HTTP request must not send'))
oid = pending(app, clients) # Disabled by default; no historical replay.
assert not notices(app)
assert configure(clients).status_code == 302
assert action(app, clients['maker'], oid, 'withdraw', comment='fix').status_code == 302
assert action(app, clients['maker'], oid, 'submit').status_code == 302
stale = order(app, oid)['version']
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
assert post(clients['reviewer'], f'/orders/{oid}/approve', {'row_version': str(stale), 'stamped': '1'}).status_code == 409
assert [n['channel'] for n in notices(app)] == ['result', 'approval', 'result']
fresh = new(app, clients['maker'])
assert save(app, clients['maker'], fresh, intent='submit', qty='-1').status_code == 400
assert len(notices(app)) == 3
# Even a failure after enqueue rolls back both order and notification.
original = notifications.enqueue_transition
def fail_after_enqueue(*args):
original(*args)
raise ValueError('simulated persistence failure')
monkeypatch.setattr(notifications, 'enqueue_transition', fail_after_enqueue)
assert save(app, clients['maker'], fresh, intent='submit').status_code == 400
assert order(app, fresh)['status'] == 'draft'
assert len(notices(app)) == 3
def test_worker_cancels_old_tasks_and_sends_minimal_lan_links(app, clients):
from notifications import dispatch_once
configure(clients, result_webhook='')
oid = pending(app, clients)
assert action(app, clients['maker'], oid, 'withdraw', comment='修改').status_code == 302
assert save(app, clients['maker'], oid, intent='submit').status_code == 302
sent = []
def sender(hook, payload):
# Sending must not hold SQLite's write lock.
with app.Session() as db:
db.execute(text('BEGIN IMMEDIATE'))
sent.append((hook, payload))
now = datetime.utcnow() + timedelta(seconds=1)
assert dispatch_once(app.Session, sender, now)
assert notices(app)[0]['status'] == 'cancelled'
assert not sent
assert dispatch_once(app.Session, sender, now)
assert notices(app)[1]['status'] == 'sent'
assert sent[0][0] == HOOK + '-approval'
content = sent[0][1]['markdown']['content']
assert f'{BASE}/orders/{oid}/view' in content
assert 'Audit supplier' not in content and 'key=' not in content
assert sent[0][1]['msgtype'] == 'markdown'
configure(clients)
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
assert not dispatch_once(app.Session, sender, now) # Shared rate limit.
assert dispatch_once(app.Session, sender, now + timedelta(seconds=5))
assert sent[-1][0] == HOOK + '-result'
assert clients['maker'].get(f'/orders/{oid}/pdf').data.startswith(b'%PDF')
def test_retry_recovery_pause_and_no_credentials_in_errors(app, clients):
from models import Notification, NotificationSettings
from notifications import dispatch_once
configure(clients)
oid = pending(app, clients)
now = datetime.utcnow() + timedelta(seconds=1)
def fail(*args):
raise RuntimeError(HOOK)
for attempt in range(5):
assert dispatch_once(app.Session, fail, now + timedelta(hours=attempt))
row = notices(app)[0]
assert row['status'] == 'failed' and row['attempts'] == 5
assert HOOK not in row['error']
assert post(clients['admin'], f"/settings/notifications/{row['id']}/retry").status_code == 302
configure(clients, enabled='0')
assert not dispatch_once(app.Session, fail, now + timedelta(hours=6))
configure(clients)
# Recover a crashed sender after its lease expires.
with app.Session() as db:
notice = db.get(Notification, row['id'])
notice.status = 'sending'
notice.lease_token = 'expired'
notice.lease_until = now
db.commit()
sent = []
assert dispatch_once(app.Session, lambda *a: sent.append(a), now + timedelta(hours=6))
assert len(sent) == 1 and notices(app)[0]['status'] == 'sent'
assert not dispatch_once(app.Session, fail, now + timedelta(hours=7))
assert post(clients['admin'], f"/settings/notifications/{row['id']}/retry").status_code == 409
html = clients['admin'].get('/settings/notifications').get_data(as_text=True)
assert HOOK not in html and '已发送' in html
def test_parallel_workers_claim_once(app, clients):
from notifications import dispatch_once
configure(clients)
pending(app, clients)
sent = []
now = datetime.utcnow() + timedelta(seconds=1)
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda _: dispatch_once(app.Session, lambda *a: sent.append(a), now), range(4)))
assert results.count(True) == 1 and len(sent) == 1
def test_settings_permissions_secret_preservation_and_testing(app, clients):
from models import NotificationSettings
for name in ('maker', 'reviewer', 'approver'):
assert clients[name].get('/settings/notifications').status_code == 403
for path in ('/settings/notifications', '/settings/notifications/test', '/settings/notifications/1/retry'):
assert post(clients[name], path).status_code == 403
assert clients['admin'].post('/settings/notifications', data={'enabled': '1'}).status_code == 400
assert configure(clients).status_code == 302
assert configure(clients, approval_webhook='', result_webhook='').status_code == 302
with app.Session() as db:
assert db.get(NotificationSettings, 1).approval_webhook == HOOK + '-approval'
html = clients['admin'].get('/settings/notifications')
assert html.headers['Cache-Control'] == 'no-store'
assert HOOK not in html.get_data(as_text=True)
for _ in range(2):
assert post(clients['admin'], '/settings/notifications/test', {'channel': 'approval'}).status_code == 302
assert len(notices(app)) == 1
assert configure(clients, clear_approval='1').status_code == 400
with app.Session() as db:
assert db.get(NotificationSettings, 1).approval_webhook == HOOK + '-approval'
assert configure(clients, clear_result='1').status_code == 302
assert post(clients['admin'], '/settings/notifications/test', {'channel': 'result'}).status_code == 400
@pytest.mark.parametrize('url', ['http://127.0.0.1:8080', 'http://localhost:8080', 'http://0.0.0.0',
'https://example.org/orders/1/view', 'https://user:pass@192.168.1.2', 'http://192.168.1.2/?x=1',
'http://192.168.1.2/#x', 'javascript:alert(1)', 'http://8.8.8.8', 'http://192.168.1.2:99999'])
def test_invalid_lan_addresses(app, clients, url):
assert configure(clients, base_url=url).status_code == 400
@pytest.mark.parametrize('url', ['http://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x',
'https://qyapi.weixin.qq.com.evil.invalid/cgi-bin/webhook/send?key=x',
HOOK + '&redirect=evil', HOOK + '#secret', 'http://192.168.1.1'])
def test_webhook_rejects_untrusted_destinations(app, clients, url):
assert configure(clients, approval_webhook=url).status_code == 400
assert 'value="' + url + '"' not in clients['admin'].get('/settings/notifications').get_data(as_text=True)
@pytest.mark.parametrize('reply,expected', [(b'{"errcode":0,"errmsg":"ok"}', None),
(b'{"errcode":45009,"errmsg":"secret url"}', '45009'), (b'{}', '格式'),
(b'{"errcode":false}', '格式'), (b'not json', '格式'), (b'x'*16385, '异常')])
def test_wecom_protocol(app, monkeypatch, reply, expected):
import notifications
class Response:
status = 200
def __enter__(self): return self
def __exit__(self, *args): pass
def read(self, count): return reply[:count]
class Opener:
def open(self, req, timeout):
assert req.full_url == HOOK and req.method == 'POST' and timeout == 5
assert json.loads(req.data)['msgtype'] == 'markdown'
return Response()
monkeypatch.setattr(notifications, 'build_opener', lambda *args: Opener())
if expected:
with pytest.raises(notifications.DeliveryError, match=expected) as error:
notifications.send_wecom(HOOK, {'msgtype': 'markdown'})
assert 'secret url' not in str(error.value)
else:
notifications.send_wecom(HOOK, {'msgtype': 'markdown'})
def test_wecom_redirect_is_not_followed(app, monkeypatch):
import notifications
class Opener:
def open(self, req, timeout):
raise HTTPError(HOOK, 302, 'redirect', {}, None)
def opener(handler):
assert handler.redirect_request(None, None, 302, '', {}, 'https://evil.invalid') is None
return Opener()
monkeypatch.setattr(notifications, 'build_opener', opener)
with pytest.raises(notifications.DeliveryError, match='302'):
notifications.send_wecom(HOOK, {})
def test_notice_login_password_return_and_scope(app, clients):
oid = pending(app, clients)
destination = f'/orders/{oid}/view'
with app.Session() as db:
db.query(app.User).filter_by(username='reviewer').one().must_change_password = True
db.commit()
c = app.app.test_client()
response = c.get(destination)
assert parse_qs(urlsplit(response.location).query)['next'] == [destination]
c.get(response.location)
response = post(c, '/login', {'username': 'reviewer', 'password': PASSWORD, 'next': destination})
assert urlsplit(response.location).path == '/account/password'
assert parse_qs(urlsplit(response.location).query)['next'] == [destination]
c.get(response.location)
new_password = PASSWORD + '-personal'
response = post(c, '/account/password', {'old_password': PASSWORD, 'password': new_password,
'confirm_password': new_password, 'next': destination})
assert response.location == destination
assert c.get(destination).status_code == 200
assert order(app, oid)['status'] == 'pending_approval' # Viewing cannot approve.
assert clients['other'].get(destination).status_code == 404
assert c.get(f'/orders/{oid}/review').status_code == 405
@pytest.mark.parametrize('destination', ['https://evil.invalid', '//evil.invalid', '/\\evil.invalid',
'/orders/1/approve', '/settings/notifications'])
def test_login_next_cannot_escape_or_execute(app, destination):
c = app.app.test_client()
c.get('/login')
assert post(c, '/login', {'username': 'maker', 'password': PASSWORD, 'next': destination}).location == '/'
+339
View File
@@ -0,0 +1,339 @@
import hashlib
import io
import json
import re
import secrets
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlsplit
import openpyxl
import pytest
from flask import Flask
from sqlalchemy import text
from werkzeug.datastructures import MultiDict
from conftest import PASSWORD, login, post
def order(app, oid):
with app.Session() as db:
o = db.get(app.Order, oid)
return {'status': o.status, 'version': o.row_version, 'revision': o.revision,
'total': o.total_amount, 'supplier': o.supplier_name,
'items': [{'qty': i.qty, 'price': i.price, 'date': i.delivery_date} for i in o.items]}
def new(app, client, kind='purchase', key=None):
response = post(client, '/orders/new', {'doc_type': kind, 'request_key': key or secrets.token_hex(16)})
assert response.status_code == 302, response.get_data(as_text=True)
return int(re.search(r'/orders/(\d+)', response.location)[1])
def form(version, qty='3', price='12.5', n=1, **overrides):
values = MultiDict({'row_version': str(version), 'supplier_name': 'Audit supplier', 'our_company': '苏州博特维智能科技有限公司',
'payment_terms': '月结30天', 'delivery_place': '苏州工业园区', 'order_date': '2026-09-16'})
for i in range(n):
for k,v in {'code': 'TEST-%03d' % i, 'spec': '测试物料 A&B <规格>', 'unit': '件',
'qty': qty, 'price': price, 'date': '2026-10-01', 'remark': ''}.items():
values.add('item_' + k, v)
for key, value in overrides.items():
values[key] = value
return values
def save(app, client, oid, **kwargs):
return post(client, '/orders/%s/edit' % oid, form(order(app, oid)['version'], **kwargs))
def action(app, client, oid, name, **kwargs):
return post(client, '/orders/%s/%s' % (oid, name), {'row_version': str(order(app, oid)['version']), **kwargs})
def pending(app, clients, **kwargs):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid, intent='submit', **kwargs).status_code == 302
return oid
def approve(app, clients, oid):
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
@pytest.mark.parametrize('kind,n', [('purchase', 3), ('outsourcing', 30)])
def test_full_flow_and_frozen_pdf(app, clients, kind, n):
from models import OrderVersion
oid = new(app, clients['maker'], kind)
assert save(app, clients['maker'], oid, n=n, intent='submit').status_code == 302
assert order(app, oid)['status'] == 'pending_approval'
assert action(app, clients['maker'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['approver'], oid, 'approve').status_code == 400
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 302
assert order(app, oid)['status'] == 'approved'
response = clients['maker'].get('/orders/%s/pdf' % oid)
assert response.data.startswith(b'%PDF') and response.data.count(b'%PDF-') == 1
assert clients['maker'].get('/orders/%s/pdf' % oid).data == response.data
import pymupdf
with pymupdf.open(stream=response.data, filetype='pdf') as doc:
content = ''.join(p.get_text() for p in doc)
assert content.count('采购专用章') == 1
assert '审核专用章' not in content and '批准专用章' not in content
with app.Session() as db:
v = db.query(OrderVersion).filter_by(order_id=oid, revision=1).one()
assert v.pdf_sha256 == hashlib.sha256(response.data).hexdigest()
assert len(json.loads(v.content_json)['items']) == n
assert v.template_version == 'po-v3'
assert save(app, clients['maker'], oid).status_code == 403
assert clients['maker'].get('/orders/%s/edit' % oid).status_code == 302
assert clients['maker'].get('/orders/%s/versions/1' % oid).status_code == 200
def test_cross_maker_access_all_paths(app, clients):
oid = pending(app, clients)
number = None
with app.Session() as db:
number = db.get(app.Order, oid).order_no
other = clients['other']
for suffix in ('view','edit','pdf','versions/1'):
assert other.get('/orders/%s/%s' % (oid,suffix)).status_code == 404
assert number not in other.get('/orders').get_data(as_text=True)
assert 'Audit supplier' not in other.get('/stats').get_data(as_text=True)
ledger = openpyxl.load_workbook(io.BytesIO(other.get('/export/ledger').data)).active
assert ledger.max_row == 1
assert action(app, other, oid, 'copy', request_key=secrets.token_hex(16)).status_code == 404
assert other.get('/exports/old.pdf').status_code == 404
def test_maker_separation_single_approval_and_rejection(app, clients):
for name in ('reviewer','approver'):
assert post(clients[name], '/orders/new', {'request_key': secrets.token_hex(16)}).status_code == 403
oid = new(app, clients['admin'])
assert save(app, clients['admin'], oid, intent='submit').status_code == 302
assert action(app, clients['admin'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['admin'], oid, 'reject', comment='self').status_code == 403
assert action(app, clients['reviewer'], oid, 'reject', comment='需要修改').status_code == 302
assert order(app, oid)['status'] == 'rejected'
assert save(app, clients['admin'], oid, intent='submit').status_code == 302
assert order(app, oid)['revision'] == 2
assert action(app, clients['reviewer'], oid, 'approve', stamped='1').status_code == 302
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 403
assert action(app, clients['approver'], oid, 'reject', comment='already done').status_code == 403
oid2 = pending(app, clients)
assert action(app, clients['admin'], oid2, 'approve', stamped='1').status_code == 302
def test_old_review_form_cannot_finally_approve(app, clients):
oid = pending(app, clients)
before = order(app, oid)
assert action(app, clients['reviewer'], oid, 'review', stamped='1').status_code == 409
assert order(app, oid) == before
with app.Session() as db:
assert not db.get(app.Order, oid).approved_by
def test_pdf_failure_rolls_back_approval_and_notification(app, clients, monkeypatch):
import order_routes
from models import Notification, OrderVersion
from test_notifications import configure
configure(clients)
oid = pending(app, clients)
before = order(app, oid)
def fail(order):
raise ValueError('PDF generation failed')
monkeypatch.setattr(order_routes, 'pdf_bytes', fail)
assert action(app, clients['approver'], oid, 'approve', stamped='1').status_code == 400
assert order(app, oid) == before
with app.Session() as db:
assert not db.get(app.Order, oid).approved_by
assert db.query(Notification).count() == 1
assert db.query(OrderVersion).filter_by(order_id=oid).one().pdf_data is None
@pytest.mark.parametrize('qty,price', [('-2','10'),('abc','10'),('nan','10'),('Infinity','10'),('1','-1'),('1','0.00001')])
def test_bad_numbers_do_not_replace_saved_order(app, clients, qty, price):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid).status_code == 302
before = order(app, oid)
assert save(app, clients['maker'], oid, qty=qty, price=price).status_code == 400
assert order(app, oid) == before
@pytest.mark.parametrize('changes', [{'supplier_name':''},{'qty':'0'},{'price':'0'},{'order_date':'bad'}])
def test_submit_validation_atomic(app, clients, changes):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid).status_code == 302
before = order(app, oid)
assert save(app, clients['maker'], oid, intent='submit', **changes).status_code == 400
assert order(app, oid) == before
def test_money_and_save_submit_latest_values(app, clients):
oid = new(app, clients['maker'])
assert save(app, clients['maker'], oid, qty='1',price='0.334',n=2).status_code == 302
assert order(app, oid)['total'] == Decimal('0.66')
assert save(app, clients['maker'], oid, qty='3',price='0.3333',intent='submit').status_code == 302
assert order(app, oid)['total'] == Decimal('1.00')
assert order(app, oid)['items'][0]['price'] == Decimal('0.3333')
with app.engine.connect() as con:
assert con.execute(text('SELECT total_cents, typeof(total_cents) FROM orders WHERE id=:id'), {'id': oid}).one() == (100,'integer')
from money import line_amount
assert line_amount('1','2.675') == Decimal('2.68')
def workbook(bad=False):
wb = openpyxl.Workbook()
wb.active.append(['物料代码','物料规格','单位','数量','含税单价','交期','备注'])
wb.active.append(['X','日期测试','件', 'oops' if bad else 3, 0.3333, datetime(2026,10,1),''])
out = io.BytesIO()
wb.save(out)
out.seek(0)
return out
def test_chinese_filename_excel_date_and_atomic_import(app, clients):
oid = new(app, clients['maker'])
response = action(app, clients['maker'], oid, 'import_excel', mode='replace', file=(workbook(), '采购明细.xlsx'))
assert response.status_code == 302
assert order(app, oid)['items'] == [{'qty': Decimal('3'), 'price': Decimal('0.3333'), 'date':'2026-10-01'}]
before = order(app, oid)
assert action(app, clients['maker'], oid, 'import_excel', mode='replace', file=(workbook(True), '采购明细.xlsx')).status_code == 400
assert order(app, oid) == before
def test_xls_native_date_and_masterdata_chinese_uploads(app, clients):
import xlwt
wb=xlwt.Workbook(); ws=wb.add_sheet('Sheet1')
for i,h in enumerate(['物料代码','物料规格','单位','数量','单价','交期','备注']): ws.write(0,i,h)
for i,v in enumerate(['XLS','旧版日期','件',2,3]): ws.write(1,i,v)
ws.write(1,5,datetime(2026,10,1),xlwt.easyxf(num_format_str='YYYY-MM-DD'))
out=io.BytesIO(); wb.save(out); out.seek(0)
oid=new(app,clients['maker'])
assert action(app,clients['maker'],oid,'import_excel',file=(out,'中文明细.xls')).status_code==302
assert order(app,oid)['items'][0]['date']=='2026-10-01'
for endpoint in ['materials','suppliers']:
template=clients['admin'].get('/%s/import_template.xlsx'%endpoint).data
response=post(clients['admin'],'/%s/import_excel'%endpoint,{'file':(io.BytesIO(template),'中文模板.xlsx')})
assert response.status_code==302
with app.Session() as db:
assert db.query(app.Material).count()==2
assert db.query(app.Supplier).count()==1
def test_old_secret_csrf_disabled_and_changed_password_sessions(app, clients):
shadow = Flask('old')
shadow.secret_key = 'botw-po-system-2026'
cookie = shadow.session_interface.get_signing_serializer(shadow).dumps({'user_id':1,'session_version':1})
forged = app.app.test_client()
forged.set_cookie('session', cookie)
assert urlsplit(forged.get('/users').location).path == '/login'
assert clients['maker'].post('/orders/new', data={'request_key':secrets.token_hex(16)}).status_code == 400
assert clients['maker'].post('/orders/new', data={'csrf_token':'无效标记'}).status_code == 400
with app.Session() as db:
u = db.query(app.User).filter_by(username='reviewer').one()
uid = u.id
oid = pending(app, clients)
assert post(clients['admin'], '/users', {'id':uid,'username':'reviewer','name':'reviewer','role':'approver','active':'0'}).status_code == 302
assert urlsplit(action(app, clients['reviewer'], oid, 'approve', stamped='1').location).path == '/login'
assert post(clients['admin'], '/users', {'id':uid,'username':'reviewer','name':'reviewer','role':'approver','active':'1','password':'Reset-Password-2026'}).status_code == 302
c = app.app.test_client()
c.get('/login')
assert urlsplit(post(c,'/login',{'username':'reviewer','password':'Reset-Password-2026'}).location).path == '/account/password'
c.get('/account/password')
assert post(c,'/account/password',{'old_password':'Reset-Password-2026','password':PASSWORD,'confirm_password':PASSWORD}).status_code == 302
c.get('/')
assert action(app, c, oid, 'approve', stamped='1').status_code == 302
def test_failed_db_write_isolated_and_last_admin_protected(app, clients):
result = post(clients['admin'], '/users', {'username':'maker','name':'duplicate','role':'maker','password':PASSWORD})
assert result.status_code == 409
assert clients['maker'].get('/orders').status_code == 200
with app.Session() as db:
uid = db.query(app.User).filter_by(username='admin').one().id
assert post(clients['admin'], '/users', {'id':uid,'username':'admin','name':'admin','role':'maker','active':'1'}).status_code == 400
def test_withdraw_revise_void_and_preserved_versions(app, clients):
from models import OrderVersion
oid = pending(app, clients)
assert action(app, clients['maker'], oid, 'withdraw', comment='修改价格').status_code == 302
assert save(app, clients['maker'], oid, price='20', intent='submit').status_code == 302
approve(app, clients, oid)
old_pdf = clients['maker'].get('/orders/%s/pdf' % oid).data
assert action(app, clients['maker'], oid, 'revise', comment='再次变更').status_code == 302
assert order(app, oid)['status'] == 'draft'
assert clients['maker'].get('/orders/%s/pdf' % oid).data != old_pdf
with app.Session() as db:
v = db.query(OrderVersion).filter_by(order_id=oid, revision=2).one()
assert v.pdf_data == old_pdf
assert json.loads(v.content_json)['items'][0]['price'] == '20.0000'
assert save(app, clients['maker'], oid, price='21', intent='submit').status_code == 302
approve(app, clients, oid)
assert order(app, oid)['revision'] == 3
assert action(app, clients['approver'], oid, 'void', comment='停止采购').status_code == 302
assert order(app, oid)['status'] == 'voided'
assert save(app, clients['maker'], oid).status_code == 403
assert clients['maker'].get('/orders/%s/pdf' % oid).data != old_pdf
def test_stale_forms_and_concurrent_number_allocation(app, clients):
oid = new(app, clients['maker'])
old = order(app,oid)['version']
assert save(app, clients['maker'], oid).status_code == 302
assert post(clients['maker'], '/orders/%s/edit'%oid, form(old)).status_code == 409
sessions = [login(app, 'maker') for _ in range(8)]
with ThreadPoolExecutor(max_workers=8) as pool:
ids = list(pool.map(lambda c: new(app,c), sessions))
assert len(set(ids)) == 8
with app.Session() as db:
numbers = [o.order_no for o in db.query(app.Order)]
assert len(set(numbers)) == len(numbers)
key = secrets.token_hex(16)
assert new(app, clients['maker'],key=key) == new(app,clients['maker'],key=key)
def test_simultaneous_approval_has_one_winner(app, clients):
oid = pending(app,clients)
revision = order(app,oid)['version']
reviewers = [login(app, name) for name in ('reviewer', 'approver')]
def approve_once(c):
return post(c, '/orders/%s/approve'%oid, {'row_version':str(revision),'stamped':'1'}).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
statuses = list(pool.map(approve_once, reviewers))
assert sorted(statuses) == [302,409]
with app.Session() as db:
assert db.query(app.ApprovalLog).filter_by(order_id=oid,action='审批通过并归档正式 PDF').count() == 1
def test_pages_and_all_post_forms_have_csrf(app, clients):
from html.parser import HTMLParser
class Forms(HTMLParser):
def __init__(self):
super().__init__(); self.active=None; self.forms=[]
def handle_starttag(self,tag,attrs):
attrs=dict(attrs)
if tag=='form' and attrs.get('method','').lower()=='post': self.active=set()
if tag=='input' and self.active is not None: self.active.add(attrs.get('name'))
def handle_endtag(self,tag):
if tag=='form' and self.active is not None: self.forms.append(self.active); self.active=None
oid = new(app,clients['maker'])
for page in ['/','/orders','/orders/new','/orders/%s/edit'%oid,'/orders/%s/view'%oid,
'/users','/users?edit=2','/suppliers','/materials','/stats','/account/password','/settings/notifications']:
response=clients['admin'].get(page)
assert response.status_code==200,(page,response.status_code)
parser=Forms();parser.feed(response.get_data(as_text=True))
assert all('csrf_token' in fields for fields in parser.forms),page
def test_setup_requires_local_token(app):
with app.Session() as db:
db.query(app.User).delete(); db.commit()
c=app.app.test_client(); c.get('/setup')
body={'username':'owner','name':'Owner','password':PASSWORD,'setup_token':'wrong'}
assert post(c,'/setup',body).status_code==403
body['setup_token']=(Path(app.INSTANCE)/'setup.token').read_text().strip()
assert post(c,'/setup',body).status_code==302
assert c.get('/setup').location.endswith('/login')