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

222 lines
11 KiB
Python

"""合同证件适用范围差量保存,全部使用内存数据库。"""
from decimal import Decimal
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture
def case():
from app.models.crm_models import CrmContract, CrmContractProduct
from app.models import pl_quota_models as m
from app.services import pl_quota_contract_service as svc
engine = create_engine('sqlite://')
for model in (CrmContract, CrmContractProduct, m.PlPurchaseCert, m.PlTransportCert,
m.PlTransportCertPurchaseRef, m.PlContractCertLink, m.PlContractCertProduct,
m.PlPlanCertUsage, m.PlPlanCertUsageItem, m.PlCertQtyLog, m.PlCertOperation):
model.__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'})
for cid in ('c1', 'c2'):
db.add(CrmContract(id=cid, contract_no=cid, contract_name=cid, customer_id='customer-a',
contract_type='sale', status='approved', tenant_no='t'))
for pid, cid in [('p1', 'c1'), ('p2', 'c1'), ('p3', 'c2')]:
db.add(CrmContractProduct(id=pid, contract_id=cid, product_id='sulfuric', product_name=pid,
quantity=Decimal('100'), tenant_no='t'))
for gid in ('g1', 'g2'):
db.add(m.PlPurchaseCert(id=gid, cert_no=gid, product_name_on_cert='硫酸', total_qty=100,
quantity_unit='吨', valid_use_text='多次有效', buyer_name='买方',
seller_name='卖方', tenant_no='t'))
db.commit()
yield SimpleNamespace(db=db, user=user, svc=svc, m=m)
db.close()
engine.dispose()
def binding(cert='g1', products=('p1', 'p2')):
return {'certType': 'purchase', 'certId': cert,
'products': [{'contractProductId': p} for p in products]}
def test_many_to_many_and_unchanged_identity_without_deduction(case):
c = case
data = {'bindings': [binding(), binding('g2')]}
first = c.svc.save_contract_bindings(c.db, c.user, 'c1', data)
second = c.svc.save_contract_bindings(c.db, c.user, 'c1', data)
assert first == second
assert all(x['scopeRevision'] == 1 for x in first)
assert len(first) == 2 and all(len(x['products']) == 2 for x in first)
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().reserved_qty == 0
assert c.db.query(c.m.PlCertQtyLog).count() == 0
def test_omitted_collection_unchanged_empty_soft_deletes_and_restores(case):
c = case
initial = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
assert c.svc.save_contract_bindings(c.db, c.user, 'c1', {}) == initial
assert c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': []}) == []
assert c.db.query(c.m.PlContractCertLink).one().del_flag == 1
restored = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
assert restored[0]['id'] == initial[0]['id']
assert {p['id'] for p in restored[0]['products']} == {p['id'] for p in initial[0]['products']}
def test_invalid_product_or_duplicate_rejected_before_any_write(case):
c = case
for products in [('p3',), ('p1', 'p1')]:
with pytest.raises(HTTPException):
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding('g1'), binding('g2', products)]})
assert c.db.query(c.m.PlContractCertLink).count() == 0
with pytest.raises(HTTPException):
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding(), binding()]})
def test_foreign_id_and_tenant_rejected(case):
c = case
first = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
with pytest.raises(HTTPException):
c.svc.save_contract_bindings(c.db, c.user, 'c2', {'bindings': [{**binding(products=('p3',)), 'id': first[0]['id']}]})
other = SimpleNamespace(**{**c.user.__dict__, 'tenant_no': 'other'})
with pytest.raises(HTTPException):
c.svc.get_contract_bindings(c.db, other, 'c1')
def test_omitted_products_keeps_scope_and_invalid_explicit_child_id_rejected(case):
c = case
first = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
assert c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [
{'id': first[0]['id'], 'certType': 'purchase', 'certId': 'g1'}]}) == first
with pytest.raises(HTTPException):
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [
{**binding(), 'products': [{'id': 'foreign', 'contractProductId': 'p1'}]}]})
assert c.svc.get_contract_bindings(c.db, c.user, 'c1') == first
@pytest.mark.parametrize('cert_status', ['active', 'void'])
def test_usage_reference_protects_removal_and_changes_only_after_prevalidation(case, cert_status):
c = case
first = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status = cert_status
link = first[0]
scope = next(p for p in link['products'] if p['contractProductId'] == 'p1')
c.db.add(c.m.PlPlanCertUsageItem(
id='usage-item', usage_id='usage', tenant_no='t', contract_cert_link_id=link['id'],
contract_cert_product_id=scope['id'], contract_scope_revision=link['scopeRevision'],
contract_id='c1', contract_product_id='p1', plan_product_id='pp1',
source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp1',
allocation_seq=1, source_unit='吨', planned_source_qty=10,
))
c.db.flush()
for data in ({'bindings': []}, {'bindings': [binding(products=('p2',))]},
{'bindings': [{**binding(), 'linkStatus': 'inactive'}]}):
with pytest.raises(HTTPException) as error:
c.svc.save_contract_bindings(c.db, c.user, 'c1', data)
assert error.value.status_code == 409
assert c.svc.get_contract_bindings(c.db, c.user, 'c1') == first
def test_contract_scope_is_enforced_before_write(case, monkeypatch):
c = case
def deny(*args, **kwargs):
raise ValueError('no permission')
monkeypatch.setattr(c.svc, 'ensure_crm_owner_row_in_scope', deny)
with pytest.raises(HTTPException) as error:
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
assert error.value.status_code == 403
assert c.db.query(c.m.PlContractCertLink).count() == 0
@pytest.mark.parametrize('products,status', [(('p1', 'p2'), None), (('p1',), None), (('p1', 'p2'), 'inactive')])
def test_void_cert_allows_preserving_or_reducing_existing_scope(case, products, status):
c = case
first = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status = 'void'
c.db.flush()
item = binding(products=products)
if status:
item['linkStatus'] = status
result = c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [item]})
assert result[0]['id'] == first[0]['id']
assert {p['contractProductId'] for p in result[0]['products']} == set(products)
@pytest.mark.parametrize('change', ['new_link', 'restore_link', 'activate_link', 'new_product', 'restore_product', 'activate_product'])
def test_void_cert_rejects_scope_expansion(case, change):
c = case
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding(products=('p1',))]})
link = c.db.query(c.m.PlContractCertLink).one()
product = c.db.query(c.m.PlContractCertProduct).one()
item = binding(products=('p1',))
if change == 'new_link':
item = binding('g2')
elif change == 'restore_link':
link.del_flag = 1
elif change == 'activate_link':
link.link_status = 'inactive'
item['linkStatus'] = 'active'
elif change == 'new_product':
item = binding()
elif change == 'restore_product':
product.del_flag = 1
else:
product.scope_status = 'inactive'
item['products'][0]['scopeStatus'] = 'active'
c.db.query(c.m.PlPurchaseCert).filter_by(id=item['certId']).one().status = 'void'
c.db.flush()
with pytest.raises(HTTPException) as error:
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [item]})
assert error.value.status_code == 400
def test_scope_and_reference_validation_use_current_reads_after_certificate_lock(case):
"""检查 ORM 发出的锁读及刷新选项;SQLite 不验证 MySQL 的真实锁行为。"""
from sqlalchemy import event
from app.models.crm_models import CrmContract, CrmContractProduct
c = case
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
reads = []
def capture(state):
if state.is_select:
reads.append((state.bind_mapper.class_, state.statement._for_update_arg is not None,
state.load_options._populate_existing))
event.listen(c.db, 'do_orm_execute', capture)
try:
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding(products=('p1',))]})
finally:
event.remove(c.db, 'do_orm_execute', capture)
cert_index = next(i for i, (model, lock, _) in enumerate(reads) if model == c.m.PlPurchaseCert and lock)
contract_index = next(i for i, (model, lock, _) in enumerate(reads) if model == CrmContract and lock)
assert cert_index < contract_index
assert reads[cert_index][2]
for model in (CrmContractProduct, c.m.PlContractCertLink, c.m.PlContractCertProduct, c.m.PlPlanCertUsageItem):
first = next(read for read in reads[contract_index + 1:] if read[0] == model)
assert first[1:] == (True, True), model.__name__
def test_new_certificate_discovered_after_contract_lock_rejects_before_mutation(case, monkeypatch):
c = case
original = c.svc._contract
locked = []
get_row = c.svc._get_row
def record(*args, **kwargs):
if kwargs.get('lock'):
locked.append(args[3])
return get_row(*args, **kwargs)
def concurrent_binding(db, user, contract_id, lock=False):
row = original(db, user, contract_id, lock=lock)
if lock:
db.add(c.m.PlContractCertLink(id='concurrent', tenant_no='t', contract_id='c1', contract_no_snapshot='c1',
cert_type='purchase', cert_id='g2', link_status='active', scope_revision=1))
db.flush()
return row
monkeypatch.setattr(c.svc, '_contract', concurrent_binding)
monkeypatch.setattr(c.svc, '_get_row', record)
with pytest.raises(HTTPException) as error:
c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding()]})
assert error.value.status_code == 409
assert locked == ['g1']
assert c.db.query(c.m.PlContractCertLink).one().del_flag == 0