Files
shanxia2.0/backend/app/services/pl_quota_customer_select.py
T
wuzilongandCursor ea46761f40 feat(pl): 合同用证改为客户用证并修复列表筛选。
证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00

225 lines
8.0 KiB
Python

"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
from datetime import date
from decimal import Decimal
from fastapi import HTTPException
from app.models.pl_quota_models import (
PlPurchaseCert,
PlTransportCert,
PlTransportCertPurchaseRef,
PlCertQtyLog,
)
from app.schemas.pl.quota_planning import ManualQuantity
from app.services.pl_cert_category_service import category_ids_for_product
from app.services.pl_quota_cert_service import is_single_use_consumed
from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio
def _available(cert):
return Decimal(cert.total_qty) - Decimal(cert.used_qty) - Decimal(cert.reserved_qty)
def _in_force(cert, on_date: date) -> bool:
if cert.valid_from and on_date < cert.valid_from:
return False
if cert.valid_to and on_date > cert.valid_to:
return False
return True
def _has_opening(db, user, cert_type, cert_id) -> bool:
return (
db.query(PlCertQtyLog.id)
.filter_by(
tenant_no=user.tenant_no,
cert_type=cert_type,
cert_id=cert_id,
event_type='opening',
del_flag=0,
)
.first()
is not None
)
def _vehicle_ok(cert, vehicle_no: str) -> bool:
plates = cert.vehicle_numbers_json or []
if not isinstance(plates, list):
return False
plate = (vehicle_no or '').strip()
if not plate:
return False
return plate in {str(p).strip() for p in plates if p}
def _permit_qty_for(source_qty: Decimal, source_unit: str, cert_unit: str) -> Decimal:
if same_quantity_unit(source_unit, cert_unit):
return source_qty
ratio = standard_conversion_ratio(source_unit, cert_unit)
if ratio is None:
raise HTTPException(409, f'来源单位「{source_unit}」与证件单位「{cert_unit}」无法自动换算,请调整单位或证件')
return (source_qty * Decimal(str(ratio))).quantize(Decimal('0.000001'))
def _customer_id_from_source(source) -> str:
snap = source.snapshot or {}
cid = snap.get('customerId') or snap.get('customer_id')
if cid:
return str(cid)
raise HTTPException(409, '用证段次缺少客户,无法按客户选证')
def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: date):
"""返回 purchase_cert_ids, transport_cert_ids, manual_allocations。
每个来源必须由一张购买证整笔覆盖;异地再配一张关联且车号/余量够的运输证。
"""
if not sources:
return [], [], []
purchase_ids = []
transport_ids = []
manuals = []
for source in sources:
customer_id = _customer_id_from_source(source)
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
if not categories:
raise HTTPException(
409,
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
)
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
candidates = (
db.query(PlPurchaseCert)
.filter(
PlPurchaseCert.tenant_no == user.tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.status == 'active',
PlPurchaseCert.customer_id == customer_id,
PlPurchaseCert.category_id.in_(categories),
)
.order_by(
PlPurchaseCert.valid_to.is_(None).asc(),
PlPurchaseCert.valid_to.asc(),
PlPurchaseCert.id.asc(),
)
.all()
)
chosen_purchase = None
chosen_transport = None
purchase_permit_qty = None
transport_permit_qty = None
for purchase in candidates:
if not purchase.verified_by or not purchase.verified_time:
continue
if not _has_opening(db, user, 'purchase', purchase.id):
continue
if not _in_force(purchase, on_date):
continue
if is_single_use_consumed(db, user, 'purchase', purchase):
continue
try:
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
except HTTPException:
continue
if _available(purchase) < need:
continue
if not source.requires_transport:
chosen_purchase = purchase
purchase_permit_qty = need
break
refs = (
db.query(PlTransportCertPurchaseRef)
.filter_by(
tenant_no=user.tenant_no,
purchase_cert_id=purchase.id,
del_flag=0,
)
.all()
)
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
if not transport_cert_ids:
continue
transports = (
db.query(PlTransportCert)
.filter(
PlTransportCert.tenant_no == user.tenant_no,
PlTransportCert.del_flag == 0,
PlTransportCert.status == 'active',
PlTransportCert.customer_id == customer_id,
PlTransportCert.id.in_(transport_cert_ids),
)
.order_by(
PlTransportCert.valid_to.is_(None).asc(),
PlTransportCert.valid_to.asc(),
PlTransportCert.id.asc(),
)
.all()
)
for transport in transports:
if not transport.verified_by or not transport.verified_time:
continue
if not _has_opening(db, user, 'transport', transport.id):
continue
if not _in_force(transport, on_date):
continue
if is_single_use_consumed(db, user, 'transport', transport):
continue
if not _vehicle_ok(transport, vehicle_no):
continue
try:
t_need = _permit_qty_for(Decimal(source.quantity), source.unit, transport.quantity_unit)
except HTTPException:
continue
if _available(transport) < t_need:
continue
chosen_purchase = purchase
chosen_transport = transport
purchase_permit_qty = need
transport_permit_qty = t_need
break
if chosen_purchase and chosen_transport:
break
if chosen_purchase is None:
place = '同市' if not source.requires_transport else '异地'
raise HTTPException(
409,
f'{place}段找不到余量足够、有效期内且匹配品类/车号的购买证'
f'{"和运输证" if source.requires_transport else ""},请补充证件或标记证书齐备后再安排',
)
purchase_ids.append(chosen_purchase.id)
manuals.append(
ManualQuantity(
permit_kind='purchase',
source_id=source.source_id,
permit_id=chosen_purchase.id,
source_quantity=Decimal(source.quantity),
permit_quantity=purchase_permit_qty,
)
)
if chosen_transport is not None:
transport_ids.append(chosen_transport.id)
manuals.append(
ManualQuantity(
permit_kind='transport',
source_id=source.source_id,
permit_id=chosen_transport.id,
source_quantity=Decimal(source.quantity),
permit_quantity=transport_permit_qty,
)
)
def unique(values):
return list(dict.fromkeys(values))
return unique(purchase_ids), unique(transport_ids), manuals