Files
shanxia2.0/backend/app/services/pl_quota_statistics.py
T
wuzilongandCursor 574e2d9b31 feat(pl): 运输证件五个页签支持按销售单位和购买单位模糊检索。
小程序检索沿用原有关键字,在编号和品名之外匹配证件主体以及计划供方、需方。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 12:44:58 +08:00

372 lines
20 KiB
Python

"""只读额度统计:共享缓存与授权合同分配分开展示,不重建历史余额。"""
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import and_, or_, literal, func
from fastapi import HTTPException
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
from app.models.crm_models import CrmContract, CrmContractProduct
from app.models.pl_models import PlPlan
from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope,
PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log)
from app.services.pl_data_scope import apply_pl_scope
from app.services.pl_quota_cert_service import MODELS, _get_row
ZERO = Decimal('0')
CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'}
CERT_STATUSES = {'draft', 'active', 'suspended', 'closed', 'void'}
EXPIRY_ALERTS = {'yes', 'no'}
EXPIRY_WARNING_DAYS = 7
def _text(value):
text = str(value or '').strip()
return text or None
def _choices(value, allowed):
if value in (None, ''):
return None
selected = [part.strip() for part in str(value).split(',') if part.strip()]
if not selected:
return None
if any(part not in allowed for part in selected):
raise HTTPException(400, '未知筛选值')
return selected
def _day(value, label):
if value in (None, ''):
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
raise HTTPException(400, f'{label}格式无效') from None
def _between(start, end, label):
left, right = _day(start, label), _day(end, label)
if left and right and left > right:
raise HTTPException(400, f'{label}起始不能晚于结束')
return left, right
def _decimal(value):
if value in (None, ''):
return None
try:
return Decimal(str(value))
except Exception:
raise HTTPException(400, '数量条件无效') from None
def _number_clauses(column, op, value, low, high):
operator = (op or 'between').strip() or 'between'
if operator == 'between':
clauses = []
left, right = _decimal(low), _decimal(high)
if left is not None:
clauses.append(column >= left)
if right is not None:
clauses.append(column <= right)
return clauses
number = _decimal(value)
if number is None:
return []
compared = {'eq': column == number, 'gt': column > number, 'gte': column >= number,
'lt': column < number, 'lte': column <= number}
if operator not in compared:
raise HTTPException(400, '数量条件无效')
return [compared[operator]]
def _apply_dates(query, column, start, end, label):
left, right = _between(start, end, label)
if left is not None:
query = query.filter(column >= left)
if right is not None:
query = query.filter(column <= right)
return query
def _apply_expiry_alert(query, column, selected, today=None):
"""今天起 7 天内到期为预警;已过期或不在窗口内不算预警。"""
choices = _choices(selected, EXPIRY_ALERTS)
if not choices or set(choices) == EXPIRY_ALERTS:
return query
today = today or date.today()
window = and_(column.isnot(None), column >= today, column <= today + timedelta(days=EXPIRY_WARNING_DAYS))
return query.filter(window if set(choices) == {'yes'} else ~window)
def _active(db, user, model):
return db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0)
def _contracts(db, user):
return apply_crm_owner_scope(_active(db, user, CrmContract), CrmContract,
user.to_user_info(), db, user.tenant_no)
def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None):
queries = []
for kind, model in MODELS.items():
if cert_type and kind != cert_type:
continue
if cert_types is not None and kind not in cert_types:
continue
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
seller = model.seller_name if kind == 'purchase' else model.shipper_name
buyer = model.buyer_name if kind == 'purchase' else model.consignee_name
if keyword:
query = query.filter(or_(model.cert_no.contains(keyword, autoescape=True),
model.product_name_on_cert.contains(keyword, autoescape=True),
seller.contains(keyword, autoescape=True),
buyer.contains(keyword, autoescape=True)))
if cert_no:
query = query.filter(model.cert_no.contains(cert_no, autoescape=True))
queries.append(query.with_entities(literal(kind).label('cert_type'), model.id.label('cert_id'),
*[getattr(model, field).label(field) for field in ('cert_no', 'product_name_on_cert',
'quantity_unit', 'total_qty', 'reserved_qty', 'used_qty', 'status',
'valid_from', 'valid_to', 'create_time')],
seller.label('sale_party_name'), buyer.label('buy_party_name'),
model.valid_use_text.label('valid_use_text')))
if not queries:
return None
return (queries[0].union_all(*queries[1:]) if len(queries) > 1 else queries[0]).subquery()
def _cert_summary(row):
return {'id': row.cert_id, 'certId': row.cert_id, 'certType': row.cert_type, 'certNo': row.cert_no,
'productNameOnCert': row.product_name_on_cert, 'quantityUnit': row.quantity_unit,
'totalQty': row.total_qty, 'reservedQty': row.reserved_qty, 'usedQty': row.used_qty,
'availableQty': row.total_qty - row.reserved_qty - row.used_qty, 'status': row.status,
'validFrom': row.valid_from, 'validTo': row.valid_to,
'salePartyName': row.sale_party_name or '', 'buyPartyName': row.buy_party_name or '',
'validUseText': row.valid_use_text or ''}
def _contract_summary(row):
return {'id': row.id, 'contractNo': row.contract_no, 'contractName': row.contract_name,
'contractType': row.contract_type}
def _key_filter(model, keys):
return or_(*[and_(model.cert_type == kind, model.cert_id.in_(
[ident for k, ident in keys if k == kind])) for kind in MODELS])
def _details(db, user, keys, contract_ids=None):
"""有限证件/合同范围一次读取维护身份、一次读取授权使用;不连接明细造成乘积。"""
if not keys:
return {}, []
contracts = _contracts(db, user).with_entities(CrmContract.id)
if contract_ids is not None:
contracts = contracts.filter(CrmContract.id.in_(contract_ids))
links = _active(db, user, Link).filter(_key_filter(Link, keys), Link.contract_id.in_(contracts)).all()
groups = {}
for link in links:
groups[(link.cert_type, link.cert_id, link.contract_id)] = {
'bindingId': link.id, 'linkStatus': link.link_status, 'scopeRevision': link.scope_revision,
'referenced': False, 'contractReservedQty': ZERO, 'contractUsedQty': ZERO, 'products': []}
products = {}
if links:
rows = db.query(Scope, CrmContractProduct).join(CrmContractProduct,
Scope.contract_product_id == CrmContractProduct.id).filter(
Scope.tenant_no == user.tenant_no, Scope.del_flag == 0,
Scope.contract_cert_link_id.in_([l.id for l in links]),
CrmContractProduct.tenant_no == user.tenant_no, CrmContractProduct.del_flag == 0)
by_link = {l.id: l for l in links}
for scope, product in rows:
link = by_link[scope.contract_cert_link_id]
if product.contract_id != link.contract_id:
continue
key = (link.cert_type, link.cert_id, link.contract_id)
value = {'id': product.id, 'contractProductId': product.id, 'productId': product.product_id,
'productName': product.product_name, 'model': product.model, 'unit': product.unit,
'bindingId': link.id, 'scopeId': scope.id, 'scopeStatus': scope.scope_status,
'linkStatus': link.link_status, 'reservedQty': ZERO, 'usedQty': ZERO}
groups[key]['products'].append(value)
products[(*key, product.id)] = value
plans = apply_pl_scope(_active(db, user, PlPlan), PlPlan, user, db, user.tenant_no).with_entities(PlPlan.id)
rows = db.query(Item, Usage, PlPlan, CrmContractProduct).join(Usage, Item.usage_id == Usage.id).join(
PlPlan, Usage.plan_id == PlPlan.id).join(CrmContractProduct, and_(
Item.contract_product_id == CrmContractProduct.id, Item.contract_id == CrmContractProduct.contract_id)
).filter(Item.tenant_no == user.tenant_no, Item.del_flag == 0,
Usage.tenant_no == user.tenant_no, Usage.del_flag == 0, _key_filter(Usage, keys),
Item.contract_id.in_(contracts), PlPlan.id.in_(plans),
CrmContractProduct.tenant_no == user.tenant_no, CrmContractProduct.del_flag == 0
).order_by(Item.create_time.desc(), Item.id).all()
usage_items = []
for item, usage, plan, product in rows:
key = (usage.cert_type, usage.cert_id, item.contract_id)
group = groups.setdefault(key, {'bindingId': None, 'linkStatus': None, 'scopeRevision': None,
'referenced': True, 'contractReservedQty': ZERO, 'contractUsedQty': ZERO, 'products': []})
# 证件单位不可变;异常历史单位不混入当前单位的聚合,明细仍保留追溯。
unit = keys[key[:2]]
if usage.quantity_unit == unit:
group['contractReservedQty'] += item.reserved_qty
group['contractUsedQty'] += item.used_qty
value = products.get((*key, product.id))
if value is None:
value = {'id': product.id, 'contractProductId': product.id, 'productId': product.product_id,
'productName': product.product_name, 'model': product.model, 'unit': product.unit,
'bindingId': None, 'scopeId': None, 'scopeStatus': None, 'linkStatus': None,
'reservedQty': ZERO, 'usedQty': ZERO}
products[(*key, product.id)] = value
group['products'].append(value)
value['reservedQty'] += item.reserved_qty
value['usedQty'] += item.used_qty
source_stop = next((stop for stop in (item.source_snapshot or {}).get('stops', [])
if stop.get('id') == item.source_stop_id), {})
party_name = source_stop.get('supplier_name' if item.source_stop_type == 'load' else 'customer_name')
usage_items.append({'id': item.id, 'usageId': usage.id, 'certType': usage.cert_type,
'certId': usage.cert_id, 'planId': plan.id, 'planNo': plan.plan_no,
'stopType': item.source_stop_type, 'stopId': item.source_stop_id,
'stopProductId': item.source_stop_product_id, 'planProductId': item.plan_product_id,
'contractId': item.contract_id, 'contractProductId': item.contract_product_id,
'productName': product.product_name, 'quantityUnit': usage.quantity_unit,
'partyName': party_name or '—', 'address': source_stop.get('address') or '—',
'reservedQty': item.reserved_qty, 'usedQty': item.used_qty, 'itemStatus': item.item_status})
link_ids = [group['bindingId'] for group in groups.values() if group.get('bindingId')]
referenced_ids = {row[0] for row in db.query(Item.contract_cert_link_id).filter(
Item.tenant_no == user.tenant_no, Item.del_flag == 0,
Item.contract_cert_link_id.in_(link_ids)).all()} if link_ids else set()
for group in groups.values():
# 已释放的用证数量可以是 0,只要还留有用证记录,这条关联就不能删除。
if not group.get('bindingId') or group.get('bindingId') in referenced_ids:
group['referenced'] = True
return groups, usage_items
def _contract_ids_by_party_keyword(db, user, keyword):
"""合同列表按关联证件的销售单位、购买单位模糊命中。"""
selected = []
for kind, model in MODELS.items():
seller = model.seller_name if kind == 'purchase' else model.shipper_name
buyer = model.buyer_name if kind == 'purchase' else model.consignee_name
cert_ids = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no).filter(
or_(seller.contains(keyword, autoescape=True), buyer.contains(keyword, autoescape=True))
).with_entities(model.id)
selected.append(db.query(Link.contract_id.label('contract_id')).filter(
Link.tenant_no == user.tenant_no, Link.del_flag == 0,
Link.cert_type == kind, Link.cert_id.in_(cert_ids)))
return selected[0].union(*selected[1:])
def list_contracts(db, user, keyword=None, page=1, page_size=20, contract_no=None, contract_name=None,
contract_type_in=None, cert_count_op=None, cert_count=None, cert_count_min=None,
cert_count_max=None):
certs = _certificates(db, user)
counts = (db.query(Link.contract_id.label('contract_id'), func.count(Link.id).label('cert_count'))
.join(certs, and_(Link.cert_type == certs.c.cert_type, Link.cert_id == certs.c.cert_id))
.filter(Link.tenant_no == user.tenant_no, Link.del_flag == 0)
.group_by(Link.contract_id).subquery())
query = _contracts(db, user).join(counts, counts.c.contract_id == CrmContract.id)
if keyword:
query = query.filter(or_(CrmContract.contract_no.contains(keyword, autoescape=True),
CrmContract.contract_name.contains(keyword, autoescape=True),
CrmContract.id.in_(_contract_ids_by_party_keyword(db, user, keyword))))
if _text(contract_no):
query = query.filter(CrmContract.contract_no.contains(_text(contract_no), autoescape=True))
if _text(contract_name):
query = query.filter(CrmContract.contract_name.contains(_text(contract_name), autoescape=True))
types = _choices(contract_type_in, CONTRACT_TYPES)
if types:
query = query.filter(CrmContract.contract_type.in_(types))
for clause in _number_clauses(counts.c.cert_count, cert_count_op, cert_count, cert_count_min, cert_count_max):
query = query.filter(clause)
total = query.count()
rows = query.with_entities(CrmContract, counts.c.cert_count).order_by(
CrmContract.create_time.desc(), CrmContract.id).offset((page - 1) * page_size).limit(page_size).all()
return {'list': [{**_contract_summary(contract), 'certCount': int(count or 0)} for contract, count in rows],
'total': total, 'page': page, 'pageSize': page_size}
def list_certificates(db, user, keyword=None, cert_type=None, page=1, page_size=20, cert_no=None, cert_types=None,
product_name=None, quantity_unit_in=None, status_in=None, valid_from_from=None,
valid_from_to=None, valid_to_from=None, valid_to_to=None, total_qty_op=None, total_qty=None,
total_qty_min=None, total_qty_max=None, reserved_qty_op=None, reserved_qty=None,
reserved_qty_min=None, reserved_qty_max=None, used_qty_op=None, used_qty=None,
used_qty_min=None, used_qty_max=None, available_qty_op=None, available_qty=None,
available_qty_min=None, available_qty_max=None, sale_party_name=None,
buy_party_name=None, valid_use_text=None, expiry_alert_in=None):
certs = _certificates(db, user, keyword, cert_type, cert_no, cert_types)
if certs is None:
return {'list': [], 'total': 0, 'page': page, 'pageSize': page_size}
query = db.query(certs)
if _text(product_name):
query = query.filter(certs.c.product_name_on_cert.contains(_text(product_name), autoescape=True))
if _text(sale_party_name):
query = query.filter(certs.c.sale_party_name.contains(_text(sale_party_name), autoescape=True))
if _text(buy_party_name):
query = query.filter(certs.c.buy_party_name.contains(_text(buy_party_name), autoescape=True))
if _text(valid_use_text):
query = query.filter(certs.c.valid_use_text.contains(_text(valid_use_text), autoescape=True))
query = _apply_expiry_alert(query, certs.c.valid_to, expiry_alert_in)
raw_units = [part.strip() for part in str(quantity_unit_in or '').split(',') if part.strip()]
if raw_units:
query = query.filter(certs.c.quantity_unit.in_(raw_units))
statuses = _choices(status_in, CERT_STATUSES)
if statuses:
query = query.filter(certs.c.status.in_(statuses))
query = _apply_dates(query, certs.c.valid_from, valid_from_from, valid_from_to, '有效期起')
query = _apply_dates(query, certs.c.valid_to, valid_to_from, valid_to_to, '有效期止')
available = certs.c.total_qty - certs.c.reserved_qty - certs.c.used_qty
for column, op, value, low, high in (
(certs.c.total_qty, total_qty_op, total_qty, total_qty_min, total_qty_max),
(certs.c.reserved_qty, reserved_qty_op, reserved_qty, reserved_qty_min, reserved_qty_max),
(certs.c.used_qty, used_qty_op, used_qty, used_qty_min, used_qty_max),
(available, available_qty_op, available_qty, available_qty_min, available_qty_max),
):
for clause in _number_clauses(column, op, value, low, high):
query = query.filter(clause)
total = query.count()
rows = query.order_by(certs.c.create_time.desc(), certs.c.cert_type, certs.c.cert_id).offset(
(page-1)*page_size).limit(page_size).all()
return {'list': [_cert_summary(r) for r in rows], 'total': total, 'page': page, 'pageSize': page_size}
def contract_statistics(db, user, contract_id):
contract = _contracts(db, user).filter(CrmContract.id == contract_id).first()
if contract is None:
raise HTTPException(404, '合同不存在或无权访问')
certs = _certificates(db, user)
linked = _active(db, user, Link).filter(Link.contract_id == contract_id)
# 历史使用即使已停止绑定也可追溯,但证件本身仍须有效且授权。
used = _active(db, user, Usage).join(Item, Item.usage_id == Usage.id).filter(
Item.tenant_no == user.tenant_no, Item.del_flag == 0, Item.contract_id == contract_id)
rows = db.query(certs).filter(or_(linked.filter(Link.cert_type == certs.c.cert_type,
Link.cert_id == certs.c.cert_id).exists(), used.filter(Usage.cert_type == certs.c.cert_type,
Usage.cert_id == certs.c.cert_id).exists())).order_by(certs.c.create_time.desc()).all()
keys = {(r.cert_type, r.cert_id): r.quantity_unit for r in rows}
groups, _ = _details(db, user, keys, [contract_id])
result = []
for row in rows:
group = groups.get((row.cert_type, row.cert_id, contract_id))
if group is not None:
result.append({**_cert_summary(row), **group,
'productNames': list(dict.fromkeys(p['productName'] for p in group['products']))})
return {**_contract_summary(contract), 'certificates': result}
def certificate_statistics(db, user, cert_type, cert_id):
_get_row(db, user, cert_type, cert_id)
certs = _certificates(db, user, cert_type=cert_type)
row = db.query(certs).filter(certs.c.cert_id == cert_id).one()
groups, items = _details(db, user, {(cert_type, cert_id): row.quantity_unit})
ids = [key[2] for key in groups]
contracts = _contracts(db, user).filter(CrmContract.id.in_(ids)).order_by(CrmContract.create_time.desc()).all() if ids else []
logs = _active(db, user, Log).filter(Log.cert_type == cert_type, Log.cert_id == cert_id,
Log.plan_id.is_(None), Log.usage_id.is_(None), Log.quantity_unit == row.quantity_unit)
opening = unattributed = ZERO
for event, delta in logs.with_entities(Log.event_type, func.sum(Log.used_delta)).group_by(Log.event_type):
if event == 'opening':
opening += delta
else:
unattributed += delta
return {**_cert_summary(row), 'contracts': [{**_contract_summary(c),
**groups[(cert_type, cert_id, c.id)]} for c in contracts], 'usageItems': items,
'openingUsedQty': opening, 'unattributedUsedQty': unattributed}