Files
shanxia2.0/backend/app/services/pl_quota_contract_service.py
T

212 lines
11 KiB
Python

"""合同与证件/产品行适用范围。差量保存,不扣量,不提交事务。"""
from fastapi import HTTPException
from pydantic import BaseModel
from app.crud.crm.data_scope_helpers import ensure_crm_owner_row_in_scope
from app.models.crm_models import CrmContract, CrmContractProduct
from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct, PlPlanCertUsageItem
from app.schemas.pl.quota_scope import ContractCertBindingsBody
from app.services.pl_quota_cert_service import _audit, _get_row, _row_to_dict
def _contract(db, user, contract_id, lock=False):
query = db.query(CrmContract).filter_by(id=contract_id, tenant_no=user.tenant_no, del_flag=0)
if lock:
query = query.with_for_update().populate_existing()
row = query.first()
if row is None:
raise HTTPException(404, '合同不存在')
try:
ensure_crm_owner_row_in_scope(db, row, user.to_user_info(), user.tenant_no)
except ValueError:
raise HTTPException(403, '无权访问该合同') from None
return row
def _links(db, user, contract_id, lock=False):
query = db.query(PlContractCertLink).filter_by(tenant_no=user.tenant_no, contract_id=contract_id)
if lock:
query = query.with_for_update().populate_existing()
return query.all()
def _products(db, user, link_id):
return db.query(PlContractCertProduct).filter_by(
tenant_no=user.tenant_no, contract_cert_link_id=link_id
).with_for_update().populate_existing().all()
def get_contract_bindings(db, user, contract_id, current=False):
_contract(db, user, contract_id)
links_query = db.query(PlContractCertLink).filter_by(
tenant_no=user.tenant_no, contract_id=contract_id, del_flag=0
).order_by(PlContractCertLink.create_time.desc(), PlContractCertLink.id)
if current:
links_query = links_query.with_for_update().populate_existing()
links = links_query.all()
result = []
for link in links:
_get_row(db, user, link.cert_type, link.cert_id)
item = _row_to_dict(link)
rows_query = db.query(PlContractCertProduct).filter_by(
tenant_no=user.tenant_no, contract_cert_link_id=link.id, del_flag=0
).order_by(PlContractCertProduct.create_time.desc(), PlContractCertProduct.id)
if current:
rows_query = rows_query.with_for_update().populate_existing()
rows = rows_query.all()
item['products'] = [_row_to_dict(row) for row in rows]
result.append(item)
return result
def _assert_unreferenced(db, user, *, link_id=None, product_id=None):
query = db.query(PlPlanCertUsageItem.id).filter_by(tenant_no=user.tenant_no, del_flag=0)
if product_id:
query = query.filter_by(contract_cert_product_id=product_id)
else:
query = query.filter_by(contract_cert_link_id=link_id)
if query.with_for_update().populate_existing().first():
raise HTTPException(409, '适用范围已有用证引用,不能直接移除或停用')
def _prepare_products(db, user, contract_id, link, submitted):
if submitted is None:
raise HTTPException(400, '产品集合不可为null;清空请提交[]')
old = _products(db, user, link.id) if link else []
by_id = {r.id: r for r in old if r.del_flag == 0}
by_product = {r.contract_product_id: r for r in old}
seen, pending = set(), []
for item in submitted:
pid, rid = item['contract_product_id'], item.get('id')
if pid in seen:
raise HTTPException(400, '合同产品明细重复')
seen.add(pid)
previous = by_id.get(rid) if rid else by_product.get(pid)
if rid and (previous is None or previous.contract_product_id != pid):
raise HTTPException(400, '产品范围ID无效或业务身份已改变')
product = db.query(CrmContractProduct).filter_by(
id=pid, contract_id=contract_id, tenant_no=user.tenant_no, del_flag=0
).with_for_update().populate_existing().first()
if product is None:
raise HTTPException(400, '产品行不属于当前合同')
if item.get('scope_status', 'active') is None:
raise HTTPException(400, '产品范围状态不可清空')
if previous and item.get('scope_status') == 'inactive' and previous.scope_status != 'inactive':
_assert_unreferenced(db, user, product_id=previous.id)
pending.append((previous, item))
removed = [r for r in old if r.del_flag == 0 and r.contract_product_id not in seen]
for row in removed:
_assert_unreferenced(db, user, product_id=row.id)
return pending, removed
def save_contract_bindings(db, user, contract_id, data):
payload = data.model_dump(exclude_unset=True) if isinstance(data, BaseModel) else data
body = ContractCertBindingsBody(**payload)
contract = _contract(db, user, contract_id)
if 'bindings' not in body.model_fields_set:
return get_contract_bindings(db, user, contract_id)
if body.bindings is None:
raise HTTPException(400, '证件集合不可为null;清空请提交[]')
values = body.model_dump(exclude_unset=True)['bindings']
existing = _links(db, user, contract_id)
# 与用证服务约定:先按证种/ID锁证,再锁合同及其范围。
keys = {(r.cert_type, r.cert_id) for r in existing if r.del_flag == 0}
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'):
raise HTTPException(400, '请先明确合同采购或销售方向')
# RR 下前置普通查询可能已有快照;等待证件锁后必须读最新提交并刷新 ORM 缓存。
existing = _links(db, user, contract_id, 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, '合同用证范围已变更,请重试')
by_id = {r.id: r for r in existing if r.del_flag == 0}
by_key = {(r.cert_type, r.cert_id): r for r in existing}
pending, seen = [], set()
for item in values:
key = (item['cert_type'], item['cert_id'])
if key in seen:
raise HTTPException(400, '同一合同证件重复提交')
seen.add(key)
rid = item.get('id')
previous = by_id.get(rid) if rid else by_key.get(key)
if rid and (previous is None or (previous.cert_type, previous.cert_id) != key):
raise HTTPException(400, '合同证件关联ID无效或业务身份已改变')
if item.get('link_status', 'active') is None:
raise HTTPException(400, '关联状态不可清空')
if previous and item.get('link_status') == 'inactive' and previous.link_status != 'inactive':
_assert_unreferenced(db, user, link_id=previous.id)
products = _prepare_products(db, user, contract_id, previous, item['products']) if 'products' in item else None
if certs[key].status == 'void':
expands = (previous is None or previous.del_flag != 0 or
(previous.link_status != 'active' and item.get('link_status') == 'active'))
if products is not None:
expands = expands or any(
row is None or row.del_flag != 0 or
(row.scope_status != 'active' and product.get('scope_status') == 'active')
for row, product in products[0]
)
if expands:
raise HTTPException(400, '作废证件不可新增、恢复或扩大适用范围')
pending.append((previous, item, products))
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
changed = link is None or link.del_flag != 0
if link is None:
link = PlContractCertLink(**_audit(user), contract_id=contract_id,
contract_no_snapshot=contract.contract_no, cert_type=item['cert_type'],
cert_id=item['cert_id'], scope_revision=1, link_status='active', del_flag=0)
db.add(link)
link.del_flag = 0
for field in ('link_status', 'remark', 'order_num'):
if field in item and getattr(link, field) != item[field]:
setattr(link, field, item[field]); changed = True
if products is not None:
product_updates, product_removes = products
for row, product in product_updates:
if row is None:
row = PlContractCertProduct(**_audit(user), contract_cert_link_id=link.id,
contract_product_id=product['contract_product_id'],
scope_status='active', del_flag=0)
db.add(row); changed = True
if row.del_flag:
row.del_flag = 0; changed = True
for field in ('scope_status', 'remark', 'order_num'):
if field in product and getattr(row, field) != product[field]:
setattr(row, field, product[field]); changed = True
if changed:
row.update_by = user.id
for row in product_removes:
row.del_flag = 1; row.update_by = user.id; changed = True
if changed:
if not is_new:
link.scope_revision += 1
link.update_by = user.id
for row in removed:
row.del_flag = 1; row.scope_revision += 1; row.update_by = user.id
for product in _products(db, user, row.id):
if product.del_flag == 0:
product.del_flag = 1; product.update_by = user.id