222 lines
11 KiB
Python
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
|