"""合同证件适用范围差量保存,全部使用内存数据库。""" 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