"""隔离 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