feat(pl): Save certificate archives and contract bindings atomically after validation.
This commit is contained in:
1 parent
0e2f00f777
commit
71116ed69e
6 files changed
+403
-10
No files matched your search
@@ -19,6 +19,7 @@ from app.schemas.pl.quota_scope import ContractCertBindingsBody
|
||||
from app.schemas.pl.legal_use_document import LegalDocumentsBody, StopLegalDocumentsBody
|
||||
from app.schemas.pl.quota_planning import PlanQuotaConfiguration, PlanQuotaPreview
|
||||
from app.services import pl_quota_cert_service as certs
|
||||
from app.services import pl_quota_cert_bindings as cert_bindings
|
||||
from app.services import pl_quota_contract_service as scopes
|
||||
from app.services import pl_quota_contract_query as contract_query
|
||||
from app.services import pl_legal_use_document as documents
|
||||
@@ -161,13 +162,13 @@ def recognize(cert_type: CertType, body: RecognizeBody,
|
||||
@oper_log('证件档案', business_type=1)
|
||||
def create_cert(cert_type: CertType, body: dict = Body(...),
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _write(db, lambda: certs.save_cert(db, user, cert_type, body))
|
||||
return _write(db, lambda: cert_bindings.save_cert(db, user, cert_type, body))
|
||||
|
||||
|
||||
@router.get('/{cert_type}/{cert_id}')
|
||||
def get_cert(cert_type: CertType, cert_id: str,
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _wire(certs.get_cert(db, user, cert_type, cert_id))
|
||||
return _wire(cert_bindings.get_cert(db, user, cert_type, cert_id))
|
||||
|
||||
|
||||
@router.get('/{cert_type}/{cert_id}/contracts')
|
||||
@@ -180,7 +181,7 @@ def certificate_contracts(cert_type: CertType, cert_id: str, db: Session = Depen
|
||||
@oper_log('证件档案', business_type=2, path_param='cert_id')
|
||||
def update_cert(cert_type: CertType, cert_id: str, body: dict = Body(...),
|
||||
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
|
||||
return _write(db, lambda: certs.save_cert(db, user, cert_type, body, cert_id))
|
||||
return _write(db, lambda: cert_bindings.save_cert(db, user, cert_type, body, cert_id))
|
||||
|
||||
|
||||
@router.delete('/{cert_type}/{cert_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""证面、购买证引用与多合同范围先整批校验,再在调用方事务内差量写入。"""
|
||||
from typing import List, Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
|
||||
from app.models.crm_models import CrmContract
|
||||
from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct, PlTransportCertPurchaseRef
|
||||
from app.schemas.pl.quota_cert import InputBody
|
||||
from app.schemas.pl.quota_scope import ScopeProductBody
|
||||
from app.services import pl_quota_cert_service as certs
|
||||
from app.services import pl_quota_contract_service as scopes
|
||||
|
||||
|
||||
class CertificateContractBinding(InputBody):
|
||||
id: Optional[str] = Field(None, max_length=64)
|
||||
contract_id: str = Field(min_length=1, max_length=64)
|
||||
link_status: Optional[Literal['active', 'inactive']] = None
|
||||
products: List[ScopeProductBody]
|
||||
remark: Optional[str] = Field(None, max_length=1000)
|
||||
order_num: Optional[int] = None
|
||||
|
||||
|
||||
class CertificateBindings(InputBody):
|
||||
contract_bindings: Optional[List[CertificateContractBinding]] = None
|
||||
|
||||
|
||||
def _target_links(db, user, cert_type, cert_id, current=False):
|
||||
query = db.query(PlContractCertLink).filter_by(tenant_no=user.tenant_no,
|
||||
cert_type=cert_type, cert_id=cert_id, del_flag=0)
|
||||
if current:
|
||||
query = query.with_for_update().populate_existing()
|
||||
return query.all()
|
||||
|
||||
|
||||
def _visible_contract_ids(db, user):
|
||||
query = db.query(CrmContract.id).filter_by(tenant_no=user.tenant_no, del_flag=0)
|
||||
return apply_crm_owner_scope(query, CrmContract, user.to_user_info(), db, user.tenant_no)
|
||||
|
||||
|
||||
def get_cert(db, user, cert_type, cert_id):
|
||||
result = certs.get_cert(db, user, cert_type, cert_id)
|
||||
links = db.query(PlContractCertLink).filter(PlContractCertLink.tenant_no == user.tenant_no,
|
||||
PlContractCertLink.cert_type == cert_type, PlContractCertLink.cert_id == cert_id,
|
||||
PlContractCertLink.del_flag == 0,
|
||||
PlContractCertLink.contract_id.in_(_visible_contract_ids(db, user))).order_by(
|
||||
PlContractCertLink.create_time.desc(), PlContractCertLink.id).all()
|
||||
products = db.query(PlContractCertProduct).filter(
|
||||
PlContractCertProduct.tenant_no == user.tenant_no, PlContractCertProduct.del_flag == 0,
|
||||
PlContractCertProduct.contract_cert_link_id.in_([r.id for r in links])).order_by(
|
||||
PlContractCertProduct.create_time.desc(), PlContractCertProduct.id).all() if links else []
|
||||
total_links = db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,
|
||||
cert_type=cert_type, cert_id=cert_id, del_flag=0).count()
|
||||
result['bindingsEditable'] = total_links == len(links)
|
||||
result['contractBindings'] = [{
|
||||
'id': link.id, 'contractId': link.contract_id, 'linkStatus': link.link_status,
|
||||
'remark': link.remark, 'orderNum': link.order_num,
|
||||
'products': [{'id': p.id, 'contractProductId': p.contract_product_id,
|
||||
'scopeStatus': p.scope_status, 'remark': p.remark, 'orderNum': p.order_num}
|
||||
for p in products if p.contract_cert_link_id == link.id],
|
||||
} for link in links]
|
||||
return result
|
||||
|
||||
|
||||
def _reference_ids(db, user, cert_id, submitted, current=False):
|
||||
query = db.query(PlTransportCertPurchaseRef).filter_by(tenant_no=user.tenant_no,
|
||||
transport_cert_id=cert_id)
|
||||
if current:
|
||||
query = query.with_for_update().populate_existing()
|
||||
restored = {r['reference_no']: r for r in (submitted or [])}
|
||||
ids = {r.purchase_cert_id for r in query.all() if r.purchase_cert_id and (
|
||||
not r.del_flag or (r.reference_no in restored and 'purchase_cert_id' not in restored[r.reference_no]))}
|
||||
ids.update(r['purchase_cert_id'] for r in (submitted or []) if r.get('purchase_cert_id'))
|
||||
return ids
|
||||
|
||||
|
||||
def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
payload = data.model_dump(exclude_unset=True) if isinstance(data, BaseModel) else dict(data)
|
||||
present = 'contractBindings' in payload or 'contract_bindings' in payload
|
||||
if not present:
|
||||
result = certs.save_cert(db, user, cert_type, payload, cert_id)
|
||||
return get_cert(db, user, cert_type, result['id'])
|
||||
bindings_payload = {key: payload.pop(key) for key in ('contractBindings', 'contract_bindings') if key in payload}
|
||||
body = CertificateBindings(**bindings_payload)
|
||||
if body.contract_bindings is None:
|
||||
raise HTTPException(400, '合同集合不可为null;清空请提交[]')
|
||||
# 证面独立严格解析,未识别字段不会因拆分而被丢弃。
|
||||
certs._model(cert_type)
|
||||
face = certs.SCHEMAS[cert_type][1 if cert_id else 0](**payload)
|
||||
values = body.model_dump(exclude_unset=True)['contract_bindings']
|
||||
ids = [r['contract_id'] for r in values]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise HTTPException(400, '合同重复提交')
|
||||
effective_id = cert_id or str(uuid4())
|
||||
target = (cert_type, effective_id)
|
||||
initial = _target_links(db, user, *target)
|
||||
contract_ids = set(ids) | {r.contract_id for r in initial}
|
||||
# 无权合同即拒绝整个集合替换;省略集合仍允许修改证面。
|
||||
for cid in sorted(contract_ids):
|
||||
scopes._contract(db, user, cid)
|
||||
keys = {target}
|
||||
for cid in sorted(contract_ids):
|
||||
keys.update((r.cert_type, r.cert_id) for r in scopes._links(db, user, cid) if r.del_flag == 0)
|
||||
refs = face.model_dump(exclude_unset=True).get('purchase_refs')
|
||||
if cert_type == 'transport':
|
||||
keys.update(('purchase', pid) for pid in _reference_ids(db, user, effective_id, refs))
|
||||
locked = {key: certs._get_row(db, user, *key, lock=True) for key in sorted(keys)
|
||||
if cert_id or key != target}
|
||||
# 新证仅构建临时对象,不 add/flush;全部准备成功后才能入会话。
|
||||
if not cert_id:
|
||||
locked[target] = certs.MODELS[cert_type](id=effective_id, status=face.status or 'draft')
|
||||
contracts = {cid: scopes._contract(db, user, cid, lock=True) for cid in sorted(contract_ids)}
|
||||
current = _target_links(db, user, *target, current=True)
|
||||
if any(r.contract_id not in contract_ids for r in current):
|
||||
raise HTTPException(409, '证件合同范围已变更,请重试')
|
||||
if cert_type == 'transport' and any(('purchase', pid) not in keys for pid in
|
||||
_reference_ids(db, user, effective_id, refs, current=True)):
|
||||
raise HTTPException(409, '证面引用已变更,请重试')
|
||||
prepared_face = certs.prepare_cert(db, user, cert_type, face, cert_id,
|
||||
locked=locked, effective_id=effective_id)
|
||||
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
|
||||
effective_certs = dict(locked)
|
||||
effective_certs[target] = certs.MODELS[cert_type](id=effective_id,
|
||||
status=prepared_face['values'].get('status', locked[target].status))
|
||||
requested = {item['contract_id']: item for item in values}
|
||||
prepared = []
|
||||
for cid in sorted(contract_ids):
|
||||
existing = scopes._links(db, user, cid, lock=True)
|
||||
if any((r.cert_type, r.cert_id) not in keys for r in existing if r.del_flag == 0):
|
||||
raise HTTPException(409, '合同用证范围已变更,请重试')
|
||||
entries = [{'id': r.id, 'cert_type': r.cert_type, 'cert_id': r.cert_id}
|
||||
for r in existing if r.del_flag == 0 and (r.cert_type, r.cert_id) != target]
|
||||
if cid in requested:
|
||||
item = {k: v for k, v in requested[cid].items() if k != 'contract_id'}
|
||||
item.update(cert_type=cert_type, cert_id=effective_id)
|
||||
entries.append(item)
|
||||
normalized = scopes.ContractCertBindingsBody(bindings=entries).model_dump(exclude_unset=True)['bindings']
|
||||
prepared.append(scopes.prepare_contract_bindings(db, user, contracts[cid], normalized,
|
||||
effective_certs, keys))
|
||||
row = certs.apply_cert(db, user, prepared_face)
|
||||
for scope in prepared:
|
||||
scopes.apply_contract_bindings(db, user, scope)
|
||||
db.flush()
|
||||
return get_cert(db, user, cert_type, row.id)
|
||||
@@ -141,7 +141,7 @@ def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=
|
||||
return pending, removed
|
||||
|
||||
|
||||
def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effective_id=None):
|
||||
model = _model(cert_type)
|
||||
payload = data.model_dump(exclude_unset=True) if isinstance(data, BaseModel) else data
|
||||
body = SCHEMAS[cert_type][1 if cert_id else 0](**payload)
|
||||
@@ -158,8 +158,12 @@ def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
not r.del_flag or (r.reference_no in restored and 'purchase_cert_id' not in restored[r.reference_no]))}
|
||||
purchase_ids.update(r['purchase_cert_id'] for r in (refs or []) if r.get('purchase_cert_id'))
|
||||
# 与账本统一购买证→运输证顺序;当前引用若出现未锁证件,拒绝而不倒序补锁。
|
||||
purchases = {pid: _get_row(db, user, 'purchase', pid, lock=True) for pid in sorted(purchase_ids)}
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=True) if cert_id else None
|
||||
purchases = {pid: (locked.get(('purchase', pid)) if locked is not None else
|
||||
_get_row(db, user, 'purchase', pid, lock=True)) for pid in sorted(purchase_ids)}
|
||||
if any(row is None for row in purchases.values()):
|
||||
raise HTTPException(409, '证面引用已变更,请重试')
|
||||
row = (locked[(cert_type, cert_id)] if locked is not None else
|
||||
_get_row(db, user, cert_type, cert_id, lock=True)) if cert_id else None
|
||||
historical = bool(row and _has_history(db,user,cert_type,cert_id))
|
||||
for key, value in values.items():
|
||||
column = model.__table__.columns[key]
|
||||
@@ -185,10 +189,18 @@ def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
duplicate = db.query(model).filter_by(tenant_no=user.tenant_no, cert_no=no).first()
|
||||
if duplicate and duplicate.id != cert_id:
|
||||
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
|
||||
effective_id = cert_id or str(uuid4())
|
||||
effective_id = cert_id or effective_id or str(uuid4())
|
||||
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
|
||||
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
|
||||
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
|
||||
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
|
||||
pending=pending, removed=removed, changed=changed, refs_changed=refs_changed)
|
||||
|
||||
|
||||
def apply_cert(db, user, prepared):
|
||||
model, row, values, cert_id, effective_id, pending, removed, changed, refs_changed = (
|
||||
prepared[key] for key in ('model', 'row', 'values', 'cert_id', 'effective_id',
|
||||
'pending', 'removed', 'changed', 'refs_changed'))
|
||||
# 整批校验通过后才开始修改。
|
||||
if not row:
|
||||
audit = _audit(user); audit['id'] = effective_id
|
||||
@@ -215,7 +227,12 @@ def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
previous.del_flag = 1
|
||||
previous.update_by = user.id
|
||||
db.flush()
|
||||
return get_cert(db,user,cert_type,row.id)
|
||||
return row
|
||||
|
||||
|
||||
def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
|
||||
return get_cert(db, user, cert_type, row.id)
|
||||
|
||||
|
||||
def delete_cert(db, user, cert_type, cert_id):
|
||||
|
||||
@@ -115,6 +115,14 @@ def save_contract_bindings(db, user, contract_id, data):
|
||||
keys.update((item['cert_type'], item['cert_id']) for item in values)
|
||||
certs = {key: _get_row(db, user, *key, lock=True) for key in sorted(keys)}
|
||||
contract = _contract(db, user, contract_id, lock=True)
|
||||
prepared = prepare_contract_bindings(db, user, contract, values, certs, keys)
|
||||
apply_contract_bindings(db, user, prepared)
|
||||
db.flush()
|
||||
return get_contract_bindings(db, user, contract_id, current=True)
|
||||
|
||||
|
||||
def prepare_contract_bindings(db, user, contract, values, certs, keys):
|
||||
contract_id = contract.id
|
||||
if contract.status not in ('approved', 'executing'):
|
||||
raise HTTPException(400, '仅已审批或执行中的合同可配置用证范围')
|
||||
if contract.contract_type not in ('sale', 'purchase', 'framework_sale', 'framework_purchase'):
|
||||
@@ -156,6 +164,12 @@ def save_contract_bindings(db, user, contract_id, data):
|
||||
removed = [r for r in existing if r.del_flag == 0 and (r.cert_type, r.cert_id) not in seen]
|
||||
for row in removed:
|
||||
_assert_unreferenced(db, user, link_id=row.id)
|
||||
return contract, pending, removed
|
||||
|
||||
|
||||
def apply_contract_bindings(db, user, prepared):
|
||||
contract, pending, removed = prepared
|
||||
contract_id = contract.id
|
||||
# 所有主子身份与引用校验通过,才开始写入;关系以唯一业务组合差量复用。
|
||||
for link, item, products in pending:
|
||||
is_new = link is None
|
||||
@@ -195,5 +209,3 @@ def save_contract_bindings(db, user, contract_id, data):
|
||||
for product in _products(db, user, row.id):
|
||||
if product.del_flag == 0:
|
||||
product.del_flag = 1; product.update_by = user.id
|
||||
db.flush()
|
||||
return get_contract_bindings(db, user, contract_id, current=True)
|
||||
@@ -133,3 +133,18 @@ def test_total_adjust_requires_provenance_and_replays_without_duplicate_log(api)
|
||||
assert client.post(path, json={**body,'totalQty':'40'}).status_code == 409
|
||||
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).status_code == 409
|
||||
assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
|
||||
|
||||
|
||||
def test_cert_bindings_api_accepts_collection_and_roundtrips(api):
|
||||
client, app, auth, permissions = api
|
||||
app.dependency_overrides[auth] = lambda: {'role': 'super_admin', 'user_id': 'u', 'tenant_no': 't'}
|
||||
path = '/api/transport/quota-certs/purchase'
|
||||
saved = client.post(path, json={**payload(), 'contractBindings': []})
|
||||
assert saved.status_code == 200
|
||||
row = saved.json()
|
||||
assert row['contractBindings'] == []
|
||||
detail = path + '/' + row['id']
|
||||
assert client.get(detail).json()['contractBindings'] == []
|
||||
assert client.put(detail, json={'contractBindings': [], 'remark': 'edit'}).status_code == 200
|
||||
assert client.put(detail, json={'contractBindings': [], 'unknownField': 'bad'}).status_code == 422
|
||||
assert client.get(detail).json()['remark'] == 'edit'
|
||||
@@ -0,0 +1,203 @@
|
||||
"""证件建档与多合同范围原子保存,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')]
|
||||
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
|
||||
Reference in new issue
Block a user