"""证件建档与多合同范围原子保存,SQLite 验证。""" import pytest from fastapi import HTTPException from test_pl_quota_contract_scope import case, binding def save(c, data, cert_id='g1', cert_type='purchase'): from app.services import pl_quota_cert_bindings as svc return svc.save_cert(c.db, c.user, cert_type, data, cert_id) def scopes(): return [{'contractId': 'c1', 'products': [{'contractProductId': 'p1'}]}, {'contractId': 'c2', 'products': [{'contractProductId': 'p3'}]}] def test_roundtrip_omission_empty_and_other_cert_preserved(case): c = case c.svc.save_contract_bindings(c.db, c.user, 'c1', {'bindings': [binding('g2')]}) result = save(c, {'contractBindings': scopes()}) original = result['contractBindings'] assert len(original) == 2 payload = [{k: v for k, v in x.items() if k in ('id', 'contractId', 'linkStatus', 'products', 'remark', 'orderNum')} for x in original] for item in payload: item['products'] = [{k: v for k, v in p.items() if k in ('id', 'contractProductId', 'scopeStatus')} for p in item['products']] assert save(c, {'contractBindings': payload})['contractBindings'] == original assert save(c, {'remark': 'only face'})['contractBindings'] == original assert save(c, {'contractBindings': []})['contractBindings'] == [] assert c.svc.get_contract_bindings(c.db, c.user, 'c1')[0]['certId'] == 'g2' def test_second_invalid_contract_does_not_mutate_face_or_first(case): c = case data = scopes() data[1]['products'][0]['contractProductId'] = 'p1' with pytest.raises(HTTPException): save(c, {'remark': 'must not write', 'contractBindings': data}) assert not c.db.new and not c.db.dirty assert c.db.query(c.m.PlContractCertLink).count() == 0 assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None @pytest.mark.parametrize('change', ['duplicate', 'foreign_id', 'foreign_product', 'null']) def test_bad_identity_rejected_before_writes(case, change): data = scopes() if change == 'duplicate': data.append(data[0]) if change == 'foreign_id': data[0]['id'] = 'foreign' if change == 'foreign_product': data[0]['products'][0]['id'] = 'foreign' if change == 'null': data = None with pytest.raises(HTTPException): save(case, {'contractBindings': data}) assert case.db.query(case.m.PlContractCertLink).count() == 0 def test_create_with_bindings_and_bad_create_has_no_pending_row(case): c = case body = dict(certNo='new', productNameOnCert='硫酸', totalQty='300', quantityUnit='吨', validUseText='多次有效', buyerName='买方', sellerName='卖方', contractBindings=scopes()) broken = {**body, 'contractBindings': [{'contractId': 'missing', 'products': []}]} with pytest.raises(HTTPException): save(c, broken, None) assert not c.db.new and not c.db.dirty result = save(c, body, None) assert len(result['contractBindings']) == 2 assert result['id'] not in ('g1', 'g2') def test_hidden_contract_cannot_be_cleared_but_face_can_change(case, monkeypatch): from app.services import pl_quota_cert_bindings as svc from app.models.crm_models import CrmContract c = case save(c, {'contractBindings': scopes()}) original = c.svc.ensure_crm_owner_row_in_scope def deny_second(db, row, *args): if row.id == 'c2': raise ValueError('hidden') return original(db, row, *args) monkeypatch.setattr(c.svc, 'ensure_crm_owner_row_in_scope', deny_second) monkeypatch.setattr(svc, '_visible_contract_ids', lambda db, user: db.query(CrmContract.id).filter(CrmContract.id == 'c1')) with pytest.raises(HTTPException) as error: save(c, {'contractBindings': []}) assert error.value.status_code == 403 result = save(c, {'remark': 'allowed'}) assert result['remark'] == 'allowed' assert [x['contractId'] for x in result['contractBindings']] == ['c1'] assert c.db.query(c.m.PlContractCertLink).filter_by(del_flag=0).count() == 2 def test_transport_locks_all_certificates_before_contracts_and_current_reads(case, monkeypatch): from app.services import pl_quota_cert_service as certs 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('g2')]}) certs.save_cert(c.db, c.user, 'transport', dict(certNo='T1', productNameOnCert='硫酸', totalQty='300', quantityUnit='吨', validUseText='多次有效', shipperName='发货人'), None) target = c.db.query(c.m.PlTransportCert).one().id events = [] original = certs._get_row contract = c.svc._contract def cert_lock(db, user, kind, rid, lock=False): if lock: events.append(('cert', kind, rid)) return original(db, user, kind, rid, lock) def contract_lock(db, user, cid, lock=False): if lock: events.append(('contract', cid)) return contract(db, user, cid, lock) monkeypatch.setattr(certs, '_get_row', cert_lock) monkeypatch.setattr(c.svc, '_contract', contract_lock) reads = [] def capture(state): if state.is_select and state.statement._for_update_arg is not None: reads.append((state.bind_mapper.class_, state.load_options._populate_existing)) event.listen(c.db, 'do_orm_execute', capture) try: result = save(c, {'contractBindings': scopes(), 'purchaseRefs': [ {'referenceType': 'purchase_permit', 'referenceNo': 'g1', 'purchaseCertId': 'g1'}]}, target, 'transport') finally: event.remove(c.db, 'do_orm_execute', capture) assert events == [('cert', 'purchase', 'g1'), ('cert', 'purchase', 'g2'), ('cert', 'transport', target), ('contract', 'c1'), ('contract', 'c2'), ('cert', 'transport', target)] # 响应当前读仅重复已持有的目标证锁。 for model in (CrmContract, CrmContractProduct, c.m.PlContractCertLink, c.m.PlTransportCertPurchaseRef): assert any(m == model and refresh for m, refresh in reads) assert len(result['contractBindings']) == 2 and len(result['purchaseRefs']) == 1 def test_new_key_after_lock_retries_without_face_mutation(case, monkeypatch): c = case original = c.svc._contract def changed(db, user, cid, lock=False): row = original(db, user, cid, lock) if lock and cid == 'c1': db.add(c.m.PlContractCertLink(id='concurrent', tenant_no='t', contract_id='c1', cert_type='purchase', cert_id='g2', contract_no_snapshot='c1', scope_revision=1, link_status='active')) db.flush() return row monkeypatch.setattr(c.svc, '_contract', changed) with pytest.raises(HTTPException) as error: save(c, {'remark': 'must not write', 'contractBindings': scopes()}) assert error.value.status_code == 409 assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None def test_referenced_scope_cannot_be_removed_or_disabled(case): c = case result = save(c, {'contractBindings': scopes()}) link = next(x for x in result['contractBindings'] if x['contractId'] == 'c1') c.db.add(c.m.PlPlanCertUsageItem(id='item', usage_id='usage', tenant_no='t', contract_cert_link_id=link['id'], contract_cert_product_id=link['products'][0]['id'], contract_scope_revision=1, contract_id='c1', contract_product_id='p1', plan_product_id='pp', source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp', allocation_seq=1, source_unit='吨', planned_source_qty=10)) c.db.flush() for bindings in ([], [{**scopes()[0], 'linkStatus': 'inactive'}, scopes()[1]]): with pytest.raises(HTTPException) as error: save(c, {'remark': 'do not write', 'contractBindings': bindings}) assert error.value.status_code == 409 assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().remark is None def test_void_new_or_existing_certificate_cannot_expand(case): c = case with pytest.raises(HTTPException): save(c, {'status': 'void', 'contractBindings': scopes()}) assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status == 'draft' def test_get_reports_partial_binding_scope_for_editor(case, monkeypatch): from app.services import pl_quota_cert_bindings as svc from app.models.crm_models import CrmContract c = case result = save(c, {'contractBindings': scopes()}) assert result['bindingsEditable'] is True monkeypatch.setattr(svc, '_visible_contract_ids', lambda db, user: db.query(CrmContract.id).filter(CrmContract.id == 'c1')) result = svc.get_cert(c.db, c.user, 'purchase', 'g1') assert result['bindingsEditable'] is False assert len(result['contractBindings']) == 1 def test_foreign_tenant_contract_rejected_and_no_changes(case): from app.models.crm_models import CrmContract c = case c.db.query(CrmContract).filter_by(id='c2').one().tenant_no = 'other' c.db.commit() with pytest.raises(HTTPException) as error: save(c, {'remark': 'must not write', 'contractBindings': scopes()}) assert error.value.status_code == 404 assert c.db.query(c.m.PlContractCertLink).count() == 0 assert not c.db.dirty and not c.db.new def test_transport_refs_and_face_not_written_when_second_contract_invalid(case): from app.services import pl_quota_cert_service as certs c = case result = certs.save_cert(c.db, c.user, 'transport', dict(certNo='T1', productNameOnCert='硫酸', totalQty='300', quantityUnit='吨', validUseText='多次有效', shipperName='发货人')) data = scopes() data[1]['products'] = [{'contractProductId': 'p1'}] with pytest.raises(HTTPException): save(c, {'remark': 'must not write', 'contractBindings': data, 'purchaseRefs': [ {'referenceType': 'purchase_permit', 'referenceNo': 'g1', 'purchaseCertId': 'g1'}]}, result['id'], 'transport') assert c.db.query(c.m.PlTransportCert).one().remark is None assert c.db.query(c.m.PlTransportCertPurchaseRef).count() == 0 assert c.db.query(c.m.PlContractCertLink).count() == 0 assert not c.db.dirty and not c.db.new @pytest.mark.parametrize('with_bindings', [True, False]) def test_save_response_uses_current_reads_for_face_bindings_products_and_visibility(case, monkeypatch, with_bindings): """SQLite 不模拟 RR;捕获实际 ORM 返回查询并以 MySQL 方言验证当前读意图。""" from sqlalchemy import event from sqlalchemy.dialects import mysql from app.services import pl_quota_cert_bindings as svc c = case save(c, {'contractBindings': scopes()}) reads = [] returning = [False] original = svc.get_cert def response(*args, **kwargs): returning[0] = True try: return original(*args, **kwargs) finally: returning[0] = False def capture(state): if returning[0] and state.is_select: reads.append((state.bind_mapper.class_, str(state.statement.compile(dialect=mysql.dialect())), state.load_options._populate_existing)) monkeypatch.setattr(svc, 'get_cert', response) event.listen(c.db, 'do_orm_execute', capture) try: result = save(c, {'contractBindings': scopes()} if with_bindings else {'remark': 'face only'}) finally: event.remove(c.db, 'do_orm_execute', capture) assert len(result['contractBindings']) == 2 for model in (c.m.PlPurchaseCert, c.m.PlContractCertLink, c.m.PlContractCertProduct): queries = [(sql, refresh) for m, sql, refresh in reads if m == model] assert queries, model.__name__ assert all('FOR UPDATE' in sql and refresh for sql, refresh in queries), queries def test_plain_get_does_not_acquire_write_locks(case): from sqlalchemy import event from app.services import pl_quota_cert_bindings as svc c = case save(c, {'contractBindings': scopes()}) reads = [] def capture(state): if state.is_select: reads.append(state.statement._for_update_arg is not None) event.listen(c.db, 'do_orm_execute', capture) try: result = svc.get_cert(c.db, c.user, 'purchase', 'g1') finally: event.remove(c.db, 'do_orm_execute', capture) assert len(result['contractBindings']) == 2 and not any(reads)