证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。 Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
8.0 KiB
Python
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
|