"""额度账本内存事务回归。""" from datetime import date, datetime from decimal import Decimal as D from types import SimpleNamespace import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session @pytest.fixture(params=[True, False], ids=["autoflush", "explicit_flush"]) def case(request): from app.models import pl_quota_models as m from app.models.pl_models import PlPlan from app.models.crm_models import CrmContract, CrmContractProduct engine = create_engine('sqlite://') for model in [PlPlan, CrmContract, CrmContractProduct] + [getattr(m,n) for n in dir(m) if n.startswith('Pl')]: model.__table__.create(engine) db = Session(engine, autoflush=request.param) user = SimpleNamespace(id='u',dept_id='d',tenant_no='t',to_user_info=lambda: {'role':'super_admin','user_id':'u'}) for pid in ['plan','other']: db.add(PlPlan(id=pid,plan_no=pid,create_date=date.today(),belongs_company='shanxia',plan_type='direct',tenant_no='t')) db.add(CrmContract(id='c',contract_no='c',contract_name='c',customer_id='customer',contract_type='sale',status='approved',tenant_no='t')) db.add(CrmContractProduct(id='cp',contract_id='c',product_id='product',product_name='酸',quantity=100,tenant_no='t')) for kind,model,pid in [('purchase',m.PlPurchaseCert,'p'),('transport',m.PlTransportCert,'t')]: extra = dict(buyer_name='买',seller_name='卖') if kind=='purchase' else dict(shipper_name='货主',vehicle_numbers_json=['CAR']) db.add(model(id=pid,cert_no=pid,product_name_on_cert='酸',total_qty=100,quantity_unit='吨',valid_use_text='多次有效',valid_from=date(2020,1,1),valid_to=date(2099,12,31),status='active',verified_by='u',verified_time=datetime.now(),tenant_no='t',**extra)) db.add(m.PlCertQtyLog(id='opening'+pid,operation_id='opening'+pid,request_hash='opening',cert_type=kind,cert_id=pid,event_type='opening',quantity_unit='吨',before_total_qty=100,after_total_qty=100,before_reserved_qty=0,after_reserved_qty=0,before_used_qty=0,after_used_qty=0,reason='期初',tenant_no='t')) db.add(m.PlContractCertLink(id='l'+pid,contract_id='c',contract_no_snapshot='c',cert_type=kind,cert_id=pid,tenant_no='t')) db.add(m.PlContractCertProduct(id='s'+pid,contract_cert_link_id='l'+pid,contract_product_id='cp',tenant_no='t')) db.add(m.PlTransportCertPurchaseRef(id='ref',transport_cert_id='t',reference_type='purchase_permit',reference_no='p',purchase_cert_id='p',tenant_no='t')) db.commit() yield SimpleNamespace(db=db,user=user,m=m) db.close(); engine.dispose() def request(**kw): return dict(operation_id='op',action='reserve',plan_id='plan',purchase_cert_ids=['p'],transport_cert_ids=['t'],vehicle_no='CAR',sources=[dict(source_stop_type='load',source_stop_id='stop',source_stop_product_id='sp',plan_product_id='pp',contract_id='c',contract_product_id='cp',product_id='product',quantity='29',unit='吨',fingerprint='fp')],**kw) def run(c, **kw): from app.services.pl_quota_usage_service import execute_usage body=request(); body.update(kw) return execute_usage(c.db,c.user,body) def test_retry_and_conflict(case): c=case first=run(c) assert run(c)==first assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert c.db.query(c.m.PlCertQtyLog).filter_by(event_type='reserve').count()==2 for kw in [dict(plan_id='other'),dict(action='resize'),dict(purchase_cert_ids=[])]: with pytest.raises(ValueError): run(c,**kw) def test_resize_atomic_and_identity(case): c=case; run(c) ids=[x.id for x in c.db.query(c.m.PlPlanCertUsageItem).all()] sources=request()['sources']; sources[0]['quantity']='101' with pytest.raises(ValueError): run(c,operation_id='bad',action='resize',sources=sources) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert c.db.query(c.m.PlCertOperation).count()==1 sources[0]['quantity']='30' run(c,operation_id='resize',action='resize',sources=sources) assert [x.id for x in c.db.query(c.m.PlPlanCertUsageItem).all()]==ids assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==30 def test_settle_and_adjust_delta(case): c=case; run(c) run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':'18'}) assert c.db.get(c.m.PlPurchaseCert,'p').used_qty==18 assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==0 run(c,operation_id='adjust',action='actual_adjust',actual_by_source={'load:sp':'20'},reason='更正',source_note='磅单核对') assert c.db.get(c.m.PlPurchaseCert,'p').used_qty==20 assert c.db.query(c.m.PlCertQtyLog).filter_by(operation_id='adjust',cert_type='purchase').one().used_delta==2 def test_once_zero_and_opening(case): c=case; c.db.get(c.m.PlPurchaseCert,'p').valid_use_text='一次有效'; c.db.flush() run(c) run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':'0'}) with pytest.raises(ValueError, match='已执行'): run(c,operation_id='release',action='release') assert all(u.usage_status == 'settled' for u in c.db.query(c.m.PlPlanCertUsage).all()) with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other') def test_shared_pool_and_scope(case): c=case; run(c) sources=request()['sources']; sources[0]['quantity']='80' with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other',sources=sources) c.db.get(c.m.PlContractCertProduct,'st').scope_status='inactive'; c.db.flush() with pytest.raises(ValueError): run(c,operation_id='invalid',action='resize') assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 def test_opening_required_and_consumed(case): c=case c.db.get(c.m.PlCertQtyLog,'openingp').del_flag=1; c.db.flush() with pytest.raises(ValueError,match='期初'): run(c) def test_zero_reservation_consumes_once_slot(case): c=case; c.db.get(c.m.PlPurchaseCert,'p').valid_use_text='一次有效'; c.db.flush() sources=request()['sources']; sources[0]['quantity']='0' run(c,sources=sources) assert c.db.query(c.m.PlPlanCertUsage).count()==2 with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other',sources=sources) def test_double_cert_failure_keeps_all_balances(case): c=case; c.db.get(c.m.PlTransportCert,'t').total_qty=10; c.db.flush() with pytest.raises(ValueError): run(c) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==0 assert c.db.query(c.m.PlPlanCertUsage).count()==0 assert c.db.query(c.m.PlCertOperation).count()==0 def test_mult_unit_requires_new_manual_actuals(case): c=case; c.db.get(c.m.PlTransportCert,'t').quantity_unit='kg'; c.db.flush() manual=[dict(permit_kind='transport',source_id='load:sp',permit_id='t',source_quantity='29',permit_quantity='58',note='人工核实')] run(c,manual_allocations=manual) with pytest.raises(ValueError): run(c,operation_id='fail',action='settle',actual_by_source={'load:sp':'18'}) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 actual=[dict(permit_kind='transport',source_id='load:sp',permit_id='t',source_quantity='18',permit_quantity='35',note='归档重新核实')] run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':'18'},manual_actuals=actual) assert c.db.get(c.m.PlTransportCert,'t').used_qty==35 assert c.db.get(c.m.PlPurchaseCert,'p').used_qty==18 def test_actual_missing_and_overage_are_atomic(case): c=case; run(c) for actual in [{},{'load:sp':'30'}]: with pytest.raises(ValueError): run(c,operation_id='fail',action='settle',actual_by_source=actual) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert c.db.get(c.m.PlPurchaseCert,'p').used_qty==0 def test_total_adjust_floor_and_audit(case): c=case; run(c) from app.services.pl_quota_usage_service import execute_usage data=dict(action='total_adjust',operation_id='total',cert_type='purchase',cert_id='p',total_qty='28',reason='修订',source_note='发证机关核实') with pytest.raises(ValueError): execute_usage(c.db,c.user,data) data['total_qty']='120' result=execute_usage(c.db,c.user,data) assert execute_usage(c.db,c.user,data)==result log=c.db.query(c.m.PlCertQtyLog).filter_by(event_type='total_adjust').one() assert log.total_delta==20 and log.reserved_delta==0 def test_tenant_and_contract_product_identity(case): c=case from fastapi import HTTPException from app.services.pl_quota_usage_service import execute_usage other=SimpleNamespace(**{**c.user.__dict__,'tenant_no':'foreign'}) with pytest.raises(HTTPException): execute_usage(c.db,other,request()) sources=request()['sources']; sources[0]['product_id']='fake' with pytest.raises(ValueError): run(c,sources=sources) assert c.db.query(c.m.PlCertOperation).count()==0 def test_opening_consumed_and_expiry_vehicle_block(case): c=case; p=c.db.get(c.m.PlPurchaseCert,'p') p.valid_use_text='一次有效'; p.opening_single_use_consumed=1; c.db.flush() with pytest.raises(ValueError): run(c) p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush() with pytest.raises(ValueError): run(c) p.valid_to=date(2099,12,31); c.db.flush() with pytest.raises(ValueError): run(c,vehicle_no='unknown') def test_no_cert_operation_is_still_idempotent(case): c=case first=run(c,sources=[],purchase_cert_ids=[],transport_cert_ids=[]) assert run(c,sources=[],purchase_cert_ids=[],transport_cert_ids=[])==first assert c.db.query(c.m.PlCertOperation).count()==1 def test_removal_and_restore_preserve_item_id(case): c=case; run(c) ids={i.id for i in c.db.query(c.m.PlPlanCertUsageItem).all()} run(c,action='resize',operation_id='clear',sources=[],purchase_cert_ids=[],transport_cert_ids=[]) assert all(i.del_flag==1 for i in c.db.query(c.m.PlPlanCertUsageItem).all()) run(c,action='resize',operation_id='restore') assert {i.id for i in c.db.query(c.m.PlPlanCertUsageItem).all()}==ids assert all(i.del_flag==0 for i in c.db.query(c.m.PlPlanCertUsageItem).all()) def _second_pair(c, cross=False): for kind,model,old_id,new_id,total in [('purchase',c.m.PlPurchaseCert,'p','p2',100),('transport',c.m.PlTransportCert,'t','t2',100)]: original=c.db.get(model,old_id) values={col.name:getattr(original,col.name) for col in model.__table__.columns} values.update(id=new_id,cert_no=new_id,total_qty=total) c.db.add(model(**values)) c.db.add(c.m.PlContractCertLink(id='l'+new_id,contract_id='c',contract_no_snapshot='c',cert_type=kind,cert_id=new_id,tenant_no='t')) c.db.add(c.m.PlContractCertProduct(id='s'+new_id,contract_cert_link_id='l'+new_id,contract_product_id='cp',tenant_no='t')) log=c.db.get(c.m.PlCertQtyLog,'opening'+old_id) lv={col.name:getattr(log,col.name) for col in c.m.PlCertQtyLog.__table__.columns} lv.update(id='opening'+new_id,operation_id='opening'+new_id,cert_id=new_id) c.db.add(c.m.PlCertQtyLog(**lv)) c.db.add(c.m.PlTransportCertPurchaseRef(id='ref2',transport_cert_id='t2',reference_type='purchase_permit',reference_no='p2' if not cross else 'p',purchase_cert_id='p2' if not cross else 'p',tenant_no='t')) if cross: c.db.get(c.m.PlTransportCertPurchaseRef,'ref').purchase_cert_id='p2' c.db.get(c.m.PlPurchaseCert,'p').total_qty=20 c.db.get(c.m.PlTransportCert,'t').total_qty=9 if cross else 20 c.db.flush() @pytest.mark.parametrize('actual,expected', [('29',[D('20'),D('9')]),('18',[D('18'),D('0')])]) def test_split_settlement_order(case,actual,expected): c=case; _second_pair(c) run(c,purchase_cert_ids=['p','p2'],transport_cert_ids=['t','t2']) run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':actual}) assert [c.db.get(c.m.PlPurchaseCert,p).used_qty for p in ['p','p2']]==expected assert all(u.reserved_qty==0 for u in c.db.query(c.m.PlPlanCertUsage).all()) def test_split_pairing_failure_does_not_partially_settle(case): c=case; _second_pair(c,cross=True) run(c,purchase_cert_ids=['p','p2'],transport_cert_ids=['t','t2']) with pytest.raises(ValueError): run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':'18'}) assert sum(u.reserved_qty for u in c.db.query(c.m.PlPlanCertUsage).all())==58 assert all(u.used_qty==0 for u in c.db.query(c.m.PlPlanCertUsage).all()) def test_zero_foreign_unit_still_requires_manual_pair(case): c=case; c.db.get(c.m.PlTransportCert,'t').quantity_unit='kg'; c.db.flush() sources=request()['sources']; sources[0]['quantity']='0' manual=[dict(permit_kind='transport',source_id='load:sp',permit_id='t',source_quantity='0',permit_quantity='0',note='零量人工核实')] run(c,sources=sources,manual_allocations=manual) assert c.db.query(c.m.PlPlanCertUsage).count()==2 @pytest.mark.parametrize('marker', ['request', 'in_transit', 'settled', 'snapshot']) def test_executed_release_cannot_free_multi_use_quota(case, marker): c=case; run(c) for u in c.db.query(c.m.PlPlanCertUsage).all(): if marker in ('in_transit','settled'): u.usage_status=marker if marker == 'snapshot': u.confirmation_snapshot={'executed':True} c.db.flush() with pytest.raises(ValueError, match='已执行'): run(c, operation_id='release', action='release', executed=marker=='request') assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert all(u.reserved_qty==29 for u in c.db.query(c.m.PlPlanCertUsage).all()) assert all(i.reserved_qty==29 for i in c.db.query(c.m.PlPlanCertUsageItem).all()) assert c.db.query(c.m.PlCertOperation).count()==1 @pytest.mark.parametrize('field,value', [('fingerprint','changed'),('source_stop_id','new-stop'), ('source_stop_product_id','new-product'),('plan_product_id','other-pp'),('contract_id','other-contract'), ('contract_product_id','other-cp'),('product_id','other-product'),('quantity','30'),('unit','kg'), ('related_stop_products',['new-delivery'])]) @pytest.mark.parametrize('action', ['settle','actual_adjust']) def test_settle_refuses_changed_authoritative_source(case, field, value, action): c=case; run(c) if action=='actual_adjust': run(c,operation_id='settle',action='settle',actual_by_source={'load:sp':'18'}) sources=request()['sources']; sources[0][field]=value with pytest.raises(ValueError, match='来源'): run(c,operation_id='changed',action=action,sources=sources,actual_by_source={'load:sp':'19'},reason='纠错',source_note='磅单') cert=c.db.get(c.m.PlPurchaseCert,'p') assert (cert.reserved_qty,cert.used_qty)==((0,18) if action=='actual_adjust' else (29,0)) @pytest.mark.parametrize('source_mode', ['missing', 'duplicate', 'extra']) def test_settle_requires_exact_source_set(case, source_mode): c=case; run(c); sources=request()['sources'] if source_mode=='missing': sources=[] elif source_mode=='duplicate': sources=sources+sources else: sources=sources+[{**sources[0], 'source_stop_product_id':'extra'}] with pytest.raises(ValueError, match='来源'): run(c,operation_id='settle',action='settle',sources=sources,actual_by_source={'load:sp':'18'}) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 @pytest.mark.parametrize('field', ['valid_from','valid_to']) @pytest.mark.parametrize('action', ['reserve','resize']) def test_incomplete_dates_block_reservation(case,field,action): c=case if action=='resize': run(c) setattr(c.db.get(c.m.PlPurchaseCert,'p'),field,None); c.db.flush() with pytest.raises(ValueError,match='有效期'): run(c,operation_id='date',action=action) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==(29 if action=='resize' else 0) @pytest.mark.parametrize('on_date', [date(2020,1,1), date(2099,12,31)]) def test_date_boundaries_are_inclusive(case,on_date): run(case,on_date=on_date) assert case.db.get(case.m.PlPurchaseCert,'p').reserved_qty==29 def test_preview_uses_own_reservation_without_writes(case): from app.services.pl_quota_usage_service import preview_usage c=case; run(c) before=(c.db.query(c.m.PlCertOperation).count(),c.db.query(c.m.PlCertQtyLog).count()) data=request(); data['action']='resize'; data['sources'][0]['quantity']='100' result=preview_usage(c.db,c.user,data) assert result['purchaseAllocations'][0]['permitQuantity']=='100' assert result['purchaseAllocations'][0]['ownReservedQty']=='29' assert result['purchaseAllocations'][0]['contractCertProductId']=='sp' assert result['transportAllocations'][0]['certRevision']==1 assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert (c.db.query(c.m.PlCertOperation).count(),c.db.query(c.m.PlCertQtyLog).count())==before assert not c.db.new and not c.db.dirty def test_preview_uses_same_eligibility_and_never_records_operation(case): from app.services.pl_quota_usage_service import preview_usage c=case; c.db.get(c.m.PlPurchaseCert,'p').valid_to=None; c.db.flush() with pytest.raises(ValueError,match='有效期'): preview_usage(c.db,c.user,request()) assert c.db.query(c.m.PlCertOperation).count()==0 assert c.db.query(c.m.PlPlanCertUsage).count()==0 def test_preview_insufficiency_includes_current_shared_capacity(case): from app.services.pl_quota_usage_service import preview_usage from app.services.pl_quota_allocation import InsufficientQuota c = case c.db.get(c.m.PlPurchaseCert, 'p').total_qty = D('20') c.db.flush() with pytest.raises(InsufficientQuota) as raised: preview_usage(c.db, c.user, request()) row = raised.value.diagnostics[0] assert row['certType'] == 'purchase' and D(row['demand']) == 29 assert D(row['availableUpperBound']) == 20 and D(row['gapLowerBound']) == 9 assert c.db.query(c.m.PlCertOperation).count() == 0 assert c.db.query(c.m.PlPlanCertUsage).count()==0 @pytest.mark.parametrize('action', ['reserve','resize']) @pytest.mark.parametrize('field,value', [('status','void'),('status','draft'),('contract_type','other')]) def test_current_contract_must_remain_executable(case,action,field,value): from app.models.crm_models import CrmContract c=case if action=='resize': run(c) setattr(c.db.get(CrmContract,'c'),field,value); c.db.flush() with pytest.raises(ValueError,match='合同'): run(c,operation_id='contract-changed',action=action) assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==(29 if action=='resize' else 0) def test_initial_operation_lookup_does_not_lock_missing_key(case,monkeypatch): from sqlalchemy.orm import Query from sqlalchemy.dialects import mysql c=case; reads=[]; original=Query.first def observed(query): if query.column_descriptions[0].get('entity') is c.m.PlCertOperation: reads.append(str(query.statement.compile(dialect=mysql.dialect()))) return original(query) monkeypatch.setattr(Query,'first',observed) run(c) assert reads and 'FOR UPDATE' not in reads[0] @pytest.mark.parametrize('conflict', [False,True]) def test_operation_unique_insert_arbitrates_stale_snapshot(case,monkeypatch,conflict): """真实SQLite唯一冲突;只隐藏初次快照读以模拟另一事务已提交。""" from sqlalchemy.orm import Query from sqlalchemy.dialects import mysql c=case; result=run(c); reads=[]; first=Query.first; one=Query.one def stale(query): if query.column_descriptions[0].get('entity') is c.m.PlCertOperation and not reads: reads.append((str(query.statement.compile(dialect=mysql.dialect())),False)) return None return first(query) def current(query): if query.column_descriptions[0].get('entity') is c.m.PlCertOperation: reads.append((str(query.statement.compile(dialect=mysql.dialect())),query.load_options._populate_existing)) return one(query) monkeypatch.setattr(Query,'first',stale); monkeypatch.setattr(Query,'one',current) if conflict: with pytest.raises(ValueError,match='操作号'): run(c,plan_id='other') else: assert run(c)==result assert len(reads)==2 assert 'FOR UPDATE' not in reads[0][0] assert 'FOR UPDATE' in reads[1][0] and reads[1][1] assert c.db.query(c.m.PlCertOperation).count()==1 assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert c.db.query(c.m.PlCertQtyLog).filter_by(event_type='reserve').count()==2 def test_new_current_usage_rejects_without_out_of_order_cert_lock(case,monkeypatch): """仅隐藏usage快照读;当前读必须发现未锁证件,原子409而非临时补锁。""" from sqlalchemy.orm import Query from fastapi import HTTPException from app.services import pl_quota_usage_service as svc c=case; run(c); original=Query.all; cert_get=svc._get_row; hidden=[]; locked=[] def stale(query): if (query.column_descriptions[0].get('entity') is c.m.PlPlanCertUsage and query._for_update_arg is None and not hidden): hidden.append(True) return [] return original(query) def tracked(db,user,kind,cert_id,lock=False): if lock: locked.append((kind,cert_id)) return cert_get(db,user,kind,cert_id,lock=lock) monkeypatch.setattr(Query,'all',stale); monkeypatch.setattr(svc,'_get_row',tracked) with pytest.raises(HTTPException) as exc: run(c,operation_id='release-race',action='release',purchase_cert_ids=[],transport_cert_ids=[],sources=[]) assert exc.value.status_code==409 assert locked==[] assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29 assert all(u.reserved_qty==29 for u in c.db.query(c.m.PlPlanCertUsage).all()) assert c.db.query(c.m.PlCertOperation).count()==1