212 lines
11 KiB
Python
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
|