Files
shanxia2.0/backend/tests/test_pl_quota_cert_archive.py
T

394 lines
19 KiB
Python

"""隔离 SQLite 档案回归,不连接配置数据库。"""
import importlib.util
from decimal import Decimal
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
def test_archive_module_exists():
assert importlib.util.find_spec('app.services.pl_quota_cert_service') is not None
@pytest.fixture
def ctx():
from app.models import pl_quota_models as m
from app.services import pl_quota_cert_service as svc
engine = create_engine('sqlite://')
for table in m.Base.metadata.sorted_tables:
if table.name.startswith(('pl_purchase_cert', 'pl_transport_cert', 'pl_contract_cert', 'pl_plan_cert', 'pl_cert_')):
table.create(engine)
db = Session(engine)
user = SimpleNamespace(id='u', dept_id='d', tenant_no='t', to_user_info=lambda: {'role': 'super_admin', 'user_id': 'u', 'tenant_no': 't'})
yield db, user, svc, m
db.close()
engine.dispose()
def purchase(**kw):
return dict(certNo='G1', productNameOnCert='硫酸', totalQty='100.123456', quantityUnit='吨', validUseText='多次有效', buyerName='购方', sellerName='销方', **kw)
def transport(**kw):
data = purchase()
data.pop('buyerName'); data.pop('sellerName')
data.update(certNo='Y1', shipperName='货主', vehicleNumbersJson=['皖A1', '皖A2'])
data.update(kw)
return data
def test_manual_optional_image_complete_fields(ctx):
db,u,s,m = ctx
data = purchase(buyerAddress='地址', buyerPhone='电话', buyerLegalRepresentative='甲', sellerAddress='乙址', sellerPhone='乙电话', sellerLegalRepresentative='乙', purchasePurpose='用途', issueDate='2026-01-01', validFrom='2026-01-01', validTo='2027-01-01')
row = s.save_cert(db,u,'purchase',data)
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
assert row['totalQty'] == Decimal('100.123456')
assert row['status'] == 'draft'
assert db.query(m.PlCertQtyLog).count() == 0
db.rollback()
assert db.query(m.PlPurchaseCert).count() == 0
def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
db, user, svc, models = ctx
row = svc.save_cert(db, user, 'purchase', purchase())
assert row['openingConfirmed'] is False
row = svc.confirm_cert(db, user, 'purchase', row['id'], {
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
assert changed['verifiedTime'] is None
assert changed['openingConfirmed'] is True
@pytest.mark.parametrize('qty', ['-1', 'NaN', 'Infinity', '1.0000001', '1000000000000'])
def test_decimal_validation(qty):
from app.schemas.pl.quota_cert import PurchaseCertCreate
data=purchase(); data['totalQty']=qty
with pytest.raises(ValidationError): PurchaseCertCreate(**data)
def test_reference_delta_omitted_empty_and_invalid_prewrite(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'unknown','referenceNo':'外部1'}]))
ref=row['purchaseRefs'][0]
row=s.save_cert(db,u,'transport',{'remark':'补图'},row['id'])
assert row['purchaseRefs'][0]['id']==ref['id']
row=s.save_cert(db,u,'transport',{'purchaseRefs':[{'id':ref['id'],'referenceType':'unknown','referenceNo':'外部1','remark':'留痕'}]},row['id'])
assert row['purchaseRefs'][0]['id']==ref['id']
with pytest.raises(HTTPException): s.save_cert(db,u,'transport',{'remark':'不可写','purchaseRefs':[{'id':'foreign','referenceType':'unknown','referenceNo':'X'}]},row['id'])
assert s.get_cert(db,u,'transport',row['id'])['remark']=='补图'
row=s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
assert row['purchaseRefs']==[]
assert db.query(m.PlTransportCertPurchaseRef).one().del_flag==1
def test_confirmation_and_history_protection(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
with pytest.raises((HTTPException,ValidationError)): s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','operationId':'op'})
row=s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','evidenceUrls':'https://minio/evidence','operationId':'op'})
assert row['openingSingleUseConsumed']==1 and row['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_type=='opening'
for data in ({'quantityUnit':'千克'},{'certNo':'new'},{'totalQty':'90'}):
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',data,row['id'])
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',row['id'])
def test_tenant_and_scope(ctx,monkeypatch):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
other=SimpleNamespace(**{**u.__dict__,'tenant_no':'other'})
with pytest.raises(HTTPException): s.get_cert(db,other,'purchase',row['id'])
def deny(*args): raise HTTPException(403,'数据范围')
monkeypatch.setattr(s,'assert_pl_row_in_scope',deny)
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',{'remark':'bad'},row['id'])
def opening(**kw):
data=dict(usedQty='0',singleUseConsumed=False,reason='期初核对',evidenceUrls='https://minio/evidence',operationId='opening')
data.update(kw)
return data
def test_used_transport_vehicle_identity_cannot_change(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'transport',transport())
s.confirm_cert(db,u,'transport',row['id'],opening())
with pytest.raises(HTTPException):
s.save_cert(db,u,'transport',{'vehicleNumbersJson':['皖B1']},row['id'])
def test_opening_retry_preserves_balance_and_operation_identity(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
s.confirm_cert(db,u,'purchase',row['id'],opening())
db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
assert retry['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==1
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='7'))
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second'))
def test_refs_duplicate_cross_owner_and_used_removal(ctx):
db,u,s,m=ctx
first=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'unknown','referenceNo':'A'}]))
data=transport(); data['certNo']='Y2'
second=s.save_cert(db,u,'transport',data)
ref=first['purchaseRefs'][0]
child={k:ref[k] for k in ('id','referenceType','referenceNo')}
for cert_id,refs in ((first['id'],[child,child]),(second['id'],[child])):
with pytest.raises(HTTPException): s.save_cert(db,u,'transport',{'purchaseRefs':refs},cert_id)
s.confirm_cert(db,u,'transport',first['id'],opening())
with pytest.raises(HTTPException): s.save_cert(db,u,'transport',{'purchaseRefs':[]},first['id'])
assert len(s.get_cert(db,u,'transport',first['id'])['purchaseRefs'])==1
def test_readonly_cache_and_tenant_cannot_be_written(ctx):
db,u,s,m=ctx
for field,value in [('tenantNo','other'),('usedQty','1'),('reservedQty','1'),('openingSingleUseConsumed',True)]:
with pytest.raises(ValidationError): s.save_cert(db,u,'purchase',{**purchase(),field:value})
def test_transport_all_fields_and_scope_filtered_list(ctx):
db,u,s,m=ctx
data=transport(shipperAddress='地址',shipperPhone='电话',shipperLegalRepresentative='甲',packagingText='槽罐',carrierName='运方',carrierAddressPhone='住址/电话',transportMode='汽车',routeText='路线',consigneeName='收方',applicantName='申请方')
row=s.save_cert(db,u,'transport',data)
for key,value in data.items():
assert row[key] == (Decimal(value) if key=='totalQty' else value)
assert s.list_certs(db,u,'transport')[0]['id']==row['id']
def test_reverify_after_correction_keeps_opening_and_balances(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='5'))
corrected=s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},row['id'])
assert corrected['status']=='suspended' and corrected['verifiedBy'] is None
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert result['status']=='active' and result['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==2
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert db.query(m.PlCertOperation).count()==2
def test_actual_scope_denies_user_without_roles(ctx):
from app.models.models import SysRole, SysUserRole
db,u,s,m=ctx
for model in (SysRole, SysUserRole): model.__table__.create(db.get_bind())
row=s.save_cert(db,u,'purchase',purchase())
restricted=SimpleNamespace(id='restricted',dept_id='d',tenant_no='t',to_user_info=lambda:{'role':'admin','user_id':'restricted','tenant_no':'t'})
assert s.list_certs(db,restricted,'purchase') == []
with pytest.raises(HTTPException) as error: s.get_cert(db,restricted,'purchase',row['id'])
assert error.value.status_code==403
def test_sql_and_models_have_same_columns(ctx):
import re
from pathlib import Path
db,u,s,m=ctx
sql=(Path(__file__).resolve().parents[2]/'docs/sql/2026-11-01-pl-contract-cert-quota.sql').read_text()
tables=re.findall(r'CREATE TABLE `(\w+)` \((.*?)\) ENGINE=',sql,re.S)
assert len(tables)==9
for table,body in tables:
names=re.findall(r'^ `(\w+)` ',body,re.M)
assert list(m.Base.metadata.tables[table].columns.keys())==names
def test_no_change_save_preserves_verification(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
row=s.confirm_cert(db,u,'purchase',row['id'],opening())
saved=s.save_cert(db,u,'purchase',purchase(),row['id'])
assert saved['status']=='active'
assert saved['revision']==row['revision']
assert saved['verifiedTime']==row['verifiedTime']
def test_zero_opening_can_activate_without_image(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'no-image','usedQty':'0','singleUseConsumed':False,'reason':'核实全新证,尚未使用'})
assert result['status']=='active' and result['attachmentUrls'] is None
assert result['verifiedBy']=='u' and result['verifiedTime'] is not None
def test_historical_opening_accepts_written_source_without_image(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'written-source','usedQty':'5','singleUseConsumed':True,'reason':'历史核对','sourceNote':'2026年10月纸质台账第3页,经张三核实'})
assert result['usedQty']==Decimal('5') and result['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_snapshot['sourceNote'].startswith('2026')
def test_unknown_use_text_is_preserved_and_requires_confirmation(ctx):
db,u,s,m=ctx
data=purchase(); data['validUseText']=' 按核发机关规定使用 '
row=s.save_cert(db,u,'purchase',data)
assert row['validUseText']==data['validUseText']
assert row['usagePolicy']=='needs_confirmation'
with pytest.raises(HTTPException) as error:
s.confirm_cert(db,u,'purchase',row['id'],opening())
assert '次数' in error.value.detail
assert s.get_cert(db,u,'purchase',row['id'])['status']=='draft'
assert db.query(m.PlCertQtyLog).count()==0
def test_known_use_policy_is_derived_and_readonly(ctx):
db,u,s,m=ctx
for number,text,policy in [('G1','一次有效','once'),('G2','多次有效','multiple')]:
data=purchase(); data.update(certNo=number,validUseText=text)
assert s.save_cert(db,u,'purchase',data)['usagePolicy']==policy
with pytest.raises(ValidationError):
s.save_cert(db,u,'purchase',{**purchase(),'usagePolicy':'once'})
def test_verify_refuses_unknown_use_policy(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
s.confirm_cert(db,u,'purchase',row['id'],opening())
# 模拟迁移导入的原文,不能被复核入口猜测放行。
stored=db.query(m.PlPurchaseCert).one()
stored.valid_use_text='另有规定'
stored.status='suspended'
with pytest.raises(HTTPException) as error:
s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-unknown','reason':'复核'})
assert '次数' in error.value.detail
assert stored.status=='suspended'
def test_reference_partial_update_validates_preserved_purchase_identity(ctx):
db,u,s,m=ctx
purchase_row=s.save_cert(db,u,'purchase',purchase())
row=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'purchase_permit','referenceNo':'G1','purchaseCertId':purchase_row['id']}]))
reference=row['purchaseRefs'][0]
with pytest.raises(HTTPException):
s.save_cert(db,u,'transport',{'remark':'不应保存','purchaseRefs':[{'id':reference['id'],'referenceType':'purchase_permit','referenceNo':'G2'}]},row['id'])
unchanged=s.get_cert(db,u,'transport',row['id'])
assert unchanged['remark'] is None
assert unchanged['purchaseRefs'][0]['referenceNo']=='G1'
assert unchanged['purchaseRefs'][0]['purchaseCertId']==purchase_row['id']
def test_purchase_number_change_cannot_break_active_transport_reference(ctx):
db,u,s,m=ctx
purchase_row=s.save_cert(db,u,'purchase',purchase())
transport_row=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'purchase_permit','referenceNo':'G1','purchaseCertId':purchase_row['id']}]))
with pytest.raises(HTTPException):
s.save_cert(db,u,'purchase',{'certNo':'G3','remark':'不应写入'},purchase_row['id'])
unchanged=s.get_cert(db,u,'purchase',purchase_row['id'])
assert unchanged['certNo']=='G1' and unchanged['remark'] is None
assert s.get_cert(db,u,'transport',transport_row['id'])['purchaseRefs'][0]['referenceNo']=='G1'
def test_draft_reference_can_restore_same_business_key_without_deleted_id(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'unknown','referenceNo':'A','remark':'原备注'}]))
ref=db.query(m.PlTransportCertPurchaseRef).one()
original=(ref.id,ref.create_by,ref.create_time)
s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
assert ref.del_flag==1
with pytest.raises(HTTPException):
s.save_cert(db,u,'transport',{'purchaseRefs':[{'id':ref.id,'referenceType':'unknown','referenceNo':'A'}]},row['id'])
restored=s.save_cert(db,u,'transport',{'purchaseRefs':[{'referenceType':'unknown','referenceNo':'A'}]},row['id'])
assert restored['purchaseRefs'][0]['id']==original[0]
assert (ref.id,ref.create_by,ref.create_time)==original
assert ref.remark=='原备注' and ref.del_flag==0
assert db.query(m.PlTransportCertPurchaseRef).count()==1
def test_historical_transport_cannot_restore_deleted_reference(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'unknown','referenceNo':'A'}]))
s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
s.confirm_cert(db,u,'transport',row['id'],opening())
with pytest.raises(HTTPException):
s.save_cert(db,u,'transport',{'purchaseRefs':[{'referenceType':'unknown','referenceNo':'A'}]},row['id'])
assert db.query(m.PlTransportCertPurchaseRef).one().del_flag==1
def test_transport_edit_locks_referenced_purchases_before_transport(ctx, monkeypatch):
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
t=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'purchase_permit','referenceNo':'G1','purchaseCertId':p['id']}]))
acquired=[]
original=s._get_row
def traced(db,user,kind,cert_id,lock=False):
if lock: acquired.append((kind,cert_id))
return original(db,user,kind,cert_id,lock)
monkeypatch.setattr(s,'_get_row',traced)
s.save_cert(db,u,'transport',{'purchaseRefs':[{k:v for k,v in t['purchaseRefs'][0].items() if k in ('id','referenceType','referenceNo','purchaseCertId')}]},t['id'])
assert acquired[:2]==[('purchase',p['id']),('transport',t['id'])]
def test_deleted_old_purchase_does_not_block_unrelated_reference_save(ctx):
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
t=s.save_cert(db,u,'transport',transport(purchaseRefs=[{'referenceType':'purchase_permit','referenceNo':'G1','purchaseCertId':p['id']}]))
s.save_cert(db,u,'transport',{'purchaseRefs':[]},t['id'])
s.delete_cert(db,u,'purchase',p['id'])
result=s.save_cert(db,u,'transport',{'purchaseRefs':[]},t['id'])
assert result['purchaseRefs']==[]
@pytest.mark.parametrize('action',['opening','verify'])
def test_opening_verify_stale_operation_snapshot_uses_unique_insert_arbitration(ctx,monkeypatch,action):
from sqlalchemy.orm import Query
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
initial=opening()
s.confirm_cert(db,u,'purchase',p['id'],initial)
if action=='verify':
body={'operationId':'verify-retry','reason':'重新核对'}
fn=s.verify_cert
fn(db,u,'purchase',p['id'],body)
else:
body=initial;fn=s.confirm_cert
count=db.query(m.PlCertOperation).count()
original=Query.first
missed=[]
def stale_once(query):
if query.column_descriptions[0].get('entity') is m.PlCertOperation and not missed:
missed.append(True)
return None
return original(query)
monkeypatch.setattr(Query,'first',stale_once)
result=fn(db,u,'purchase',p['id'],body)
assert missed and result['id']==p['id']
assert db.query(m.PlCertOperation).count()==count
assert db.query(m.PlCertQtyLog).count()==1
def test_rejected_opening_rolls_back_claimed_operation(ctx):
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
with pytest.raises(HTTPException):
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
assert db.query(m.PlCertOperation).count()==0
assert db.query(m.PlCertQtyLog).count()==0
def test_replay_reads_current_certificate_and_opening_not_old_snapshot(ctx,monkeypatch):
from sqlalchemy.orm import Query
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
row=db.get(m.PlPurchaseCert,p['id'])
stale=m.PlPurchaseCert(**{c.name:getattr(row,c.name) for c in row.__table__.columns})
s.confirm_cert(db,u,'purchase',p['id'],opening())
original=Query.first
def stale_reads(query):
entity=query.column_descriptions[0].get('entity')
if query._for_update_arg is None:
if entity is m.PlPurchaseCert: return stale
if entity is m.PlCertQtyLog: return None
return original(query)
monkeypatch.setattr(Query,'first',stale_reads)
replay=s.confirm_cert(db,u,'purchase',p['id'],opening())
assert replay['status']=='active' and replay['openingConfirmed'] is True