完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
@@ -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')
|
||||
Reference in New Issue
Block a user