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

377 lines
21 KiB
Python

"""额度账本内存事务回归。"""
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