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

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

128 lines
7.1 KiB
Python

"""计划用证列表:按真实占用/结转状态分组,余额可用性由只读预览实时计算。"""
from datetime import date
from fastapi import HTTPException
from sqlalchemy import case, or_, cast, String
from app.models.pl_models import PlPlan, PlPlanProduct
from app.models.pl_quota_models import PlPlanCertUsage
from app.services.pl_data_scope import apply_pl_scope
STATUSES = ('not_required', 'needs_configuration', 'needs_validation', 'reserved', 'settled', 'exception')
FOLLOW_STATUSES = {'drafting', 'wait_notice', 'pending_arrange', 'arranged', 'completed', 'cancelled'}
def _filter_date(value):
if value in (None, ''):
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
raise HTTPException(400, '计划日期格式无效') from None
def _text(value):
text = str(value or '').strip()
return text or None
def _hazard_product_names(db, user, plan_ids):
grouped = {plan_id: [] for plan_id in plan_ids}
if not plan_ids:
return {}
rows = db.query(PlPlanProduct.plan_id, PlPlanProduct.product_name).filter(
PlPlanProduct.tenant_no == user.tenant_no, PlPlanProduct.del_flag == 0,
PlPlanProduct.plan_id.in_(plan_ids),
or_(PlPlanProduct.is_toxic == 1, PlPlanProduct.is_explosive == 1),
).order_by(PlPlanProduct.product_name, PlPlanProduct.id).all()
for plan_id, name in rows:
text = (name or '').strip()
if text and text not in grouped[plan_id]:
grouped[plan_id].append(text)
return {plan_id: '、'.join(names) for plan_id, names in grouped.items() if names}
def list_plans(db, user, keyword='', status=None, page=1, page_size=20, *,
plan_no=None, license_plate=None, plan_date_from=None, plan_date_to=None,
quota_status_in=None, supplier_name=None, customer_name=None, product_name=None,
follow_status_in=None, conversion_only=False):
if status and status not in STATUSES:
raise HTTPException(400, '未知用证状态')
selected_states = [value.strip() for value in (quota_status_in or '').split(',') if value.strip()]
if any(value not in STATUSES for value in selected_states):
raise HTTPException(400, '未知用证状态')
date_from, date_to = _filter_date(plan_date_from), _filter_date(plan_date_to)
if date_from and date_to and date_from > date_to:
raise HTTPException(400, '计划日期起始不能晚于结束')
hazard_product = db.query(PlPlanProduct.id).filter(PlPlanProduct.tenant_no == user.tenant_no,
PlPlanProduct.plan_id == PlPlan.id, PlPlanProduct.del_flag == 0,
or_(PlPlanProduct.is_toxic == 1, PlPlanProduct.is_explosive == 1)).exists()
toxic = db.query(PlPlanProduct.id).filter(PlPlanProduct.tenant_no == user.tenant_no,
PlPlanProduct.plan_id == PlPlan.id, PlPlanProduct.del_flag == 0, PlPlanProduct.is_toxic == 1).exists()
def usage(*states):
return db.query(PlPlanCertUsage.id).filter(PlPlanCertUsage.tenant_no == user.tenant_no,
PlPlanCertUsage.plan_id == PlPlan.id, PlPlanCertUsage.del_flag == 0,
PlPlanCertUsage.usage_status.in_(states)).exists()
error = db.query(PlPlanCertUsage.id).filter(PlPlanCertUsage.tenant_no == user.tenant_no,
PlPlanCertUsage.plan_id == PlPlan.id, PlPlanCertUsage.del_flag == 0,
PlPlanCertUsage.confirmation_snapshot['settlementError'].as_string().isnot(None),
PlPlanCertUsage.confirmation_snapshot['settlementError'].as_string() != '').exists()
state = case((~toxic, 'not_required'),
(or_(PlPlan.archive_status == 'exception', usage('exception'), error), 'exception'),
(usage('reserved', 'in_transit'), 'reserved'), (usage('settled'), 'settled'),
(or_(PlPlan.cert_configuration.is_(None), cast(PlPlan.cert_configuration, String).in_(['null', '{}'])), 'needs_configuration'),
else_='needs_validation')
query = db.query(PlPlan, state.label('quota_state')).filter(PlPlan.tenant_no == user.tenant_no,
PlPlan.del_flag == 0, PlPlan.follow_status != 'cancelled', hazard_product)
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
if keyword:
query = query.filter(or_(
PlPlan.plan_no.contains(keyword, autoescape=True),
PlPlan.supplier_name.contains(keyword, autoescape=True),
PlPlan.customer_name.contains(keyword, autoescape=True),
))
if status:
query = query.filter(state == status)
# 表头条件必须先于统计与分页,不能只过滤已加载的一页。
if plan_no:
query = query.filter(PlPlan.plan_no.contains(plan_no, autoescape=True))
if license_plate:
query = query.filter(PlPlan.license_plate.contains(license_plate, autoescape=True))
if date_from:
query = query.filter(PlPlan.plan_date >= date_from)
if date_to:
query = query.filter(PlPlan.plan_date <= date_to)
if selected_states:
query = query.filter(state.in_(selected_states))
if _text(supplier_name):
query = query.filter(PlPlan.supplier_name.contains(_text(supplier_name), autoescape=True))
if _text(customer_name):
query = query.filter(PlPlan.customer_name.contains(_text(customer_name), autoescape=True))
if _text(product_name):
query = query.filter(db.query(PlPlanProduct.id).filter(
PlPlanProduct.tenant_no == user.tenant_no, PlPlanProduct.plan_id == PlPlan.id,
PlPlanProduct.del_flag == 0, or_(PlPlanProduct.is_toxic == 1, PlPlanProduct.is_explosive == 1),
PlPlanProduct.product_name.contains(_text(product_name), autoescape=True)).exists())
follow_states = [value.strip() for value in (follow_status_in or '').split(',') if value.strip()]
if any(value not in FOLLOW_STATUSES for value in follow_states):
raise HTTPException(400, '未知跟进状态')
if follow_states:
query = query.filter(PlPlan.follow_status.in_(follow_states))
if conversion_only:
from sqlalchemy import false as sql_false
from app.services.pl_quota_conversion import plans_needing_unit_conversion
candidate_ids = [row[0] for row in query.with_entities(PlPlan.id).all()]
needing = plans_needing_unit_conversion(db, user.tenant_no, candidate_ids)
query = query.filter(PlPlan.id.in_(needing) if needing else sql_false())
total = query.count()
rows = query.order_by(PlPlan.create_time.desc(), PlPlan.id).offset((page-1)*page_size).limit(page_size).all()
plan_ids = [plan.id for plan, _value in rows]
names = _hazard_product_names(db, user, plan_ids)
from app.services.pl_quota_conversion import plans_needing_unit_conversion
needing = plans_needing_unit_conversion(db, user.tenant_no, plan_ids)
return {'total': total, 'list': [{'id': p.id, 'planNo': p.plan_no, 'planDate': p.plan_date,
'licensePlate': p.license_plate, 'supplierName': p.supplier_name or '',
'customerName': p.customer_name or '', 'productNames': names.get(p.id, ''),
'followStatus': p.follow_status, 'quotaStatus': value,
'needsUnitConversion': p.id in needing} for p, value in rows]}