新增订单收票付款交货跟进并完善使用升级手册
增加部分付款和部分交货状态、工作台筛选、权限校验、跟进记录及 v4 数据迁移;补充 Windows EXE 构建与验收脚本。附 16 页 PDF 使用升级手册及企业微信详细配置说明、生成脚本和测试截图。验证:76 项回归测试、EXE 浏览器验收与 PDF 逐页校验通过。
This commit is contained in:
@@ -7,6 +7,54 @@ import pytest
|
||||
SCHEMA = Path(__file__).parent / 'fixtures' / 'legacy_schema.sql'
|
||||
|
||||
|
||||
def test_v3_tracking_upgrade_preserves_existing_state_and_archives(tmp_path):
|
||||
import hashlib
|
||||
from models import Order, OrderVersion, User, init_db
|
||||
path = tmp_path / 'po.db'
|
||||
engine, factory = init_db(str(path))
|
||||
old_pdf = b'%PDF-1.4 immutable v3 archive'
|
||||
digest = hashlib.sha256(old_pdf).hexdigest()
|
||||
with factory() as db:
|
||||
db.add(User(id=1, username='owner', name='Owner', role='admin', password_hash='hash',
|
||||
session_version=7, must_change_password=False))
|
||||
db.add(Order(id=1, order_no='V3-APPROVED', status='approved', revision=1, row_version=1))
|
||||
db.add(Order(id=2, order_no='V3-PENDING', status='pending_approval', revision=1))
|
||||
db.flush()
|
||||
db.add(OrderVersion(order_id=1, revision=1, content_json='{}', pdf_data=old_pdf,
|
||||
pdf_sha256=digest, template_version='po-v3'))
|
||||
db.commit()
|
||||
engine.dispose()
|
||||
with sqlite3.connect(path) as con:
|
||||
for field in ('needs_prepayment', 'invoice_status', 'delivery_status', 'payment_status',
|
||||
'payment_date', 'fulfillment_note', 'payment_note', 'fulfillment_revision', 'payment_revision'):
|
||||
con.execute('ALTER TABLE orders DROP COLUMN ' + field)
|
||||
con.execute('PRAGMA user_version=3')
|
||||
engine, factory = init_db(str(path))
|
||||
with factory() as db:
|
||||
row = db.get(Order, 1)
|
||||
assert row.status == 'approved' and row.revision == row.row_version == 1
|
||||
assert row.invoice_status == row.delivery_status == row.payment_status == 'unknown'
|
||||
assert row.fulfillment_revision == row.payment_revision == -1
|
||||
assert not row.fulfillment_stale and not row.payment_stale
|
||||
pending_order = db.get(Order, 2)
|
||||
assert pending_order.status == 'pending_approval' and pending_order.row_version == 1
|
||||
assert db.get(User, 1).session_version == 7
|
||||
archive = db.query(OrderVersion).one()
|
||||
assert (archive.pdf_data, archive.pdf_sha256, archive.template_version) == (old_pdf, digest, 'po-v3')
|
||||
engine.dispose()
|
||||
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] == 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 integrity_check').fetchone()[0] == 'ok'
|
||||
engine, _ = init_db(str(path))
|
||||
engine.dispose()
|
||||
assert len(list(tmp_path.glob('*.bak'))) == 1
|
||||
|
||||
|
||||
def legacy(path, duplicate=False):
|
||||
with sqlite3.connect(path) as con:
|
||||
con.executescript(SCHEMA.read_text(encoding='utf-8'))
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import io
|
||||
import re
|
||||
import secrets
|
||||
from datetime import date, timedelta
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
from conftest import post
|
||||
from test_workflow import action, approve, new, order, pending, save
|
||||
|
||||
|
||||
def approved(app, clients):
|
||||
oid = pending(app, clients)
|
||||
approve(app, clients, oid)
|
||||
return oid
|
||||
|
||||
|
||||
def follow(app, clients, oid, actor='maker', **values):
|
||||
fields = {'needs_prepayment': '1', 'invoice_status': 'received', 'delivery_status': 'partial',
|
||||
'fulfillment_note': '已收第一批货'}
|
||||
fields.update(values)
|
||||
return action(app, clients[actor], oid, 'follow-up', **fields)
|
||||
|
||||
|
||||
def pay(app, clients, oid, actor='approver', **values):
|
||||
fields = {'payment_status': 'paid', 'payment_date': date.today().isoformat(), 'payment_note': '银行转账'}
|
||||
fields.update(values)
|
||||
return action(app, clients[actor], oid, 'payment', **fields)
|
||||
|
||||
|
||||
def contains(client, key, number):
|
||||
response = client.get('/orders?tracking=' + key)
|
||||
assert response.status_code == 200
|
||||
return number in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_follow_payment_dashboard_and_archived_pdf(app, clients):
|
||||
oid = approved(app, clients)
|
||||
pdf = clients['maker'].get('/orders/%s/pdf' % oid).data
|
||||
assert follow(app, clients, oid).status_code == 302
|
||||
with app.Session() as db:
|
||||
number = db.get(app.Order, oid).order_no
|
||||
for key in ('prepayment', 'invoice_due', 'partial_delivery', 'unpaid'):
|
||||
assert contains(clients['maker'], key, number)
|
||||
assert not contains(clients['other'], 'invoice_due', number)
|
||||
assert pay(app, clients, oid, payment_status='partial').status_code == 302
|
||||
assert contains(clients['admin'], 'partial_payment', number)
|
||||
assert contains(clients['admin'], 'invoice_due', number)
|
||||
assert pay(app, clients, oid, actor='admin').status_code == 302
|
||||
assert not contains(clients['maker'], 'invoice_due', number)
|
||||
assert not contains(clients['maker'], 'prepayment', number)
|
||||
assert contains(clients['maker'], 'paid_undelivered', number)
|
||||
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
|
||||
assert contains(clients['approver'], 'completed', number)
|
||||
assert not contains(clients['approver'], 'paid_undelivered', number)
|
||||
assert clients['maker'].get('/orders/%s/pdf' % oid).data == pdf
|
||||
dashboard = clients['admin'].get('/').get_data(as_text=True)
|
||||
assert re.search(r'data-tracking="completed"[^>]*><div class="v">1</div>', dashboard)
|
||||
other_dashboard = clients['other'].get('/').get_data(as_text=True)
|
||||
assert number not in other_dashboard
|
||||
with app.Session() as db:
|
||||
logs = db.get(app.Order, oid).logs
|
||||
assert any(lg.user_name == 'maker' and '部分交货' in (lg.comment or '') for lg in logs)
|
||||
assert any(lg.user_name == 'admin' and '部分付款 → 已付款' in (lg.comment or '') for lg in logs)
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(clients['admin'].get('/export/ledger').data))
|
||||
headers, values = list(workbook.active.values)
|
||||
row = dict(zip(headers, values))
|
||||
assert row['付款状态'] == '已付款' and row['交货状态'] == '已交货' and row['收票状态'] == '已收票'
|
||||
workbook.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('actor,endpoint,status', [
|
||||
('maker', 'payment', 403), ('other', 'payment', 404),
|
||||
('other', 'follow-up', 404), ('approver', 'follow-up', 403),
|
||||
])
|
||||
def test_tracking_role_and_order_scope(app, clients, actor, endpoint, status):
|
||||
oid = approved(app, clients)
|
||||
function = pay if endpoint == 'payment' else follow
|
||||
assert function(app, clients, oid, actor=actor).status_code == status
|
||||
with app.Session() as db:
|
||||
row = db.get(app.Order, oid)
|
||||
assert row.payment_status == 'unpaid' and row.invoice_status == 'pending'
|
||||
|
||||
|
||||
def test_tracking_rejects_forged_fields_csrf_and_stale_page(app, clients):
|
||||
oid = approved(app, clients)
|
||||
version = order(app, oid)['version']
|
||||
assert follow(app, clients, oid, payment_status='paid').status_code == 403
|
||||
assert pay(app, clients, oid, delivery_status='delivered').status_code == 403
|
||||
assert clients['approver'].post('/orders/%s/payment' % oid, data={'payment_status': 'paid'}).status_code == 400
|
||||
assert follow(app, clients, oid).status_code == 302
|
||||
assert post(clients['approver'], '/orders/%s/payment' % oid,
|
||||
{'row_version': str(version), 'payment_status': 'paid', 'payment_date': date.today().isoformat()}).status_code == 409
|
||||
with app.Session() as db:
|
||||
assert db.get(app.Order, oid).payment_status == 'unpaid'
|
||||
|
||||
|
||||
def test_tracking_requires_approval_and_valid_input(app, clients):
|
||||
oid = new(app, clients['maker'])
|
||||
assert follow(app, clients, oid).status_code == 403
|
||||
assert pay(app, clients, oid).status_code == 403
|
||||
assert save(app, clients['maker'], oid, intent='submit').status_code == 302
|
||||
assert pay(app, clients, oid).status_code == 403
|
||||
approve(app, clients, oid)
|
||||
assert pay(app, clients, oid, payment_date='').status_code == 400
|
||||
assert pay(app, clients, oid, payment_date=(date.today()+timedelta(days=1)).isoformat()).status_code == 400
|
||||
assert pay(app, clients, oid, payment_status='invalid').status_code == 400
|
||||
assert follow(app, clients, oid, delivery_status='invalid').status_code == 400
|
||||
assert follow(app, clients, oid, fulfillment_note='x'*501).status_code == 400
|
||||
assert action(app, clients['admin'], oid, 'void', comment='取消采购').status_code == 302
|
||||
assert pay(app, clients, oid).status_code == 403
|
||||
assert follow(app, clients, oid).status_code == 403
|
||||
|
||||
|
||||
def test_corrections_require_reason_and_keep_audit(app, clients):
|
||||
oid = approved(app, clients)
|
||||
assert pay(app, clients, oid).status_code == 302
|
||||
assert pay(app, clients, oid, payment_status='unpaid').status_code == 400
|
||||
assert pay(app, clients, oid, payment_status='unpaid', reason='误登记,实际尚未转账').status_code == 302
|
||||
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
|
||||
assert follow(app, clients, oid, invoice_status='pending').status_code == 400
|
||||
assert follow(app, clients, oid, invoice_status='pending', delivery_status='pending', reason='收票和收货录入有误').status_code == 302
|
||||
with app.Session() as db:
|
||||
row = db.get(app.Order, oid)
|
||||
assert row.payment_status == 'unpaid' and row.payment_date == ''
|
||||
assert row.delivery_status == row.invoice_status == 'pending'
|
||||
assert any('误登记,实际尚未转账' in (log.comment or '') for log in row.logs)
|
||||
|
||||
|
||||
def test_revision_requires_each_role_to_recheck_and_copy_starts_fresh(app, clients):
|
||||
oid = approved(app, clients)
|
||||
follow(app, clients, oid, delivery_status='delivered')
|
||||
pay(app, clients, oid)
|
||||
with app.Session() as db:
|
||||
number = db.get(app.Order, oid).order_no
|
||||
assert action(app, clients['maker'], oid, 'revise', comment='调整数量').status_code == 302
|
||||
assert save(app, clients['maker'], oid, qty='5', intent='submit').status_code == 302
|
||||
approve(app, clients, oid)
|
||||
assert contains(clients['admin'], 'recheck', number)
|
||||
assert not contains(clients['admin'], 'completed', number)
|
||||
assert '订单内容已变更' in clients['maker'].get('/orders/%s/view' % oid).get_data(as_text=True)
|
||||
assert follow(app, clients, oid, delivery_status='delivered').status_code == 302
|
||||
assert contains(clients['admin'], 'recheck', number)
|
||||
assert pay(app, clients, oid).status_code == 302
|
||||
assert not contains(clients['admin'], 'recheck', number)
|
||||
assert contains(clients['admin'], 'completed', number)
|
||||
copied = action(app, clients['maker'], oid, 'copy', request_key=secrets.token_hex(16))
|
||||
assert copied.status_code == 302
|
||||
copy_id = int(re.search(r'/orders/(\d+)', copied.location)[1])
|
||||
with app.Session() as db:
|
||||
clone = db.get(app.Order, copy_id)
|
||||
assert not clone.needs_prepayment
|
||||
assert clone.invoice_status == clone.delivery_status == 'pending'
|
||||
assert clone.payment_status == 'unpaid' and clone.payment_date == ''
|
||||
|
||||
|
||||
def test_admin_can_follow_and_pay_and_invalid_filter_is_rejected(app, clients):
|
||||
oid = approved(app, clients)
|
||||
assert follow(app, clients, oid, actor='admin').status_code == 302
|
||||
assert pay(app, clients, oid, actor='admin').status_code == 302
|
||||
assert clients['admin'].get('/orders?tracking=invalid').status_code == 400
|
||||
Reference in New Issue
Block a user