256 lines
12 KiB
Python
256 lines
12 KiB
Python
"""证件建档与多合同范围原子保存,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)
|