Files
shanxia2.0/backend/app/services/pl_purchase_cert_service.py
T
wuzilongandCursor d22b06dbe7 feat(pl): 易制毒先安排待确认与购买证管理
含易制毒计划安排后进入已安排待确认,确认通过后再占合同并生成链接/地磅;新增购买证/运输证主数据、产品行关联、余量校验与 OCR 配置,并停止跟踪 api_key/app.pid。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 18:41:10 +08:00

1071 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""易制毒购买证 / 运输证余量、关联与占用。"""
from __future__ import annotations
import re
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Optional
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.pl_models import (
PlPlan,
PlPlanProduct,
PlPlanProductCertLink,
PlPurchaseCert,
PlTransportCert,
)
from app.services.pl_plan_service import collect_toxic_product_ids, plan_has_toxic_products
PURCHASE_CERT_WARN_TONS = 64
TRANSPORT_CERT_WARN_TONS = 64
LINK_STATUS_LINKED = "linked"
LINK_STATUS_OCCUPIED = "occupied"
LINK_STATUS_VOIDED = "voided"
# ---------------------------------------------------------------------------
# 纯函数
# ---------------------------------------------------------------------------
def calc_cert_remain(*, total: float, occupied: float) -> float:
return round(max(0.0, float(total or 0) - float(occupied or 0)), 3)
def normalize_plate_token(raw: str) -> str:
return (raw or "").strip().upper().replace("·", "").replace(" ", "")
def split_multi_tokens(raw: Optional[str]) -> list[str]:
if not raw:
return []
parts = re.split(r"[,,、;;\s]+", str(raw).strip())
return [normalize_plate_token(p) for p in parts if p.strip()]
def split_cert_nos(raw: Optional[str]) -> list[str]:
if not raw:
return []
parts = re.split(r"[,,、;;\s]+", str(raw).strip())
return [p.strip() for p in parts if p.strip()]
def plate_allowed_on_transport(plan_plate: str, transport_plates: Optional[str]) -> bool:
plate = normalize_plate_token(plan_plate)
if not plate:
return False
return plate in set(split_multi_tokens(transport_plates))
def product_name_matches(*, plan_name: str, cert_name: str) -> bool:
a = (plan_name or "").strip()
b = (cert_name or "").strip()
if not a or not b:
return False
return b in a or a in b
def permit_nos_cover_purchase(permit_cert_nos: Optional[str], purchase_cert_no: str) -> bool:
nos = set(split_cert_nos(permit_cert_nos))
target = (purchase_cert_no or "").strip()
return bool(target) and target in nos
def _in_valid_range(today: date, start: Optional[date], end: Optional[date]) -> bool:
if start and today < start:
return False
if end and today > end:
return False
return True
def validate_link_rules(
*,
plan_qty: float,
purchase_remain: float,
transport_remain: float,
plan_plate: str,
transport_plates: Optional[str],
plan_product_name: str,
purchase_product_name: str,
purchase_cert_no: str,
permit_cert_nos: Optional[str],
purchase_valid_from: Optional[date],
purchase_valid_to: Optional[date],
transport_valid_from: Optional[date],
transport_valid_to: Optional[date],
today: date,
) -> Optional[str]:
qty = float(plan_qty or 0)
if qty <= 0:
return "计划量无效"
if purchase_remain + 1e-9 < qty:
return f"购买证余量不足:剩余 {purchase_remain},本行计划 {qty}"
if transport_remain + 1e-9 < qty:
return f"运输证余量不足:剩余 {transport_remain},本行计划 {qty}"
if not plate_allowed_on_transport(plan_plate, transport_plates):
return f"计划车牌 {plan_plate} 不在运输证车号范围内"
if not product_name_matches(plan_name=plan_product_name, cert_name=purchase_product_name):
return "计划品名与购买证购买物品不匹配"
if not permit_nos_cover_purchase(permit_cert_nos, purchase_cert_no):
return "运输证上的购买许可/备案证号未覆盖所选购买证"
if not _in_valid_range(today, purchase_valid_from, purchase_valid_to):
return "购买证不在有效期内"
if not _in_valid_range(today, transport_valid_from, transport_valid_to):
return "运输证不在有效期内"
return None
# ---------------------------------------------------------------------------
# 占用汇总
# ---------------------------------------------------------------------------
def sum_occupied_qty(
db: Session,
*,
tenant_no: str,
purchase_cert_id: Optional[str] = None,
transport_cert_id: Optional[str] = None,
exclude_link_ids: Optional[set[str]] = None,
) -> float:
q = db.query(func.coalesce(func.sum(PlPlanProductCertLink.occupy_plan_qty), 0)).filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status == LINK_STATUS_OCCUPIED,
)
if purchase_cert_id:
q = q.filter(PlPlanProductCertLink.purchase_cert_id == purchase_cert_id)
if transport_cert_id:
q = q.filter(PlPlanProductCertLink.transport_cert_id == transport_cert_id)
if exclude_link_ids:
q = q.filter(~PlPlanProductCertLink.id.in_(list(exclude_link_ids)))
return float(q.scalar() or 0)
def _f(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _status_for_cert(
*,
remain: float,
total: float,
valid_to: Optional[date],
today: date,
warn_tons: float = PURCHASE_CERT_WARN_TONS,
) -> str:
if total > 0 and remain <= 0:
return "exhausted"
if valid_to and 0 <= (valid_to - today).days <= 15:
return "expiring"
if remain <= warn_tons:
return "remain_warn"
return "has_remain"
def _purchase_to_dict(
row: PlPurchaseCert,
*,
remain: float,
occupied: float,
transport_count: int = 0,
transport_remain_sum: float = 0.0,
transport_warn: bool = False,
linked_plan_count: int = 0,
today: Optional[date] = None,
) -> dict[str, Any]:
today = today or date.today()
status = _status_for_cert(
remain=remain, total=_f(row.total_qty), valid_to=row.valid_to, today=today
)
if transport_warn and status == "has_remain":
status = "transport_warn"
return {
"id": row.id,
"certNo": row.cert_no,
"issueDate": row.issue_date.isoformat() if row.issue_date else None,
"buyerName": row.buyer_name,
"buyerLegal": row.buyer_legal,
"buyerTel": row.buyer_tel,
"buyerAddr": row.buyer_addr,
"sellerName": row.seller_name,
"sellerLegal": row.seller_legal,
"sellerTel": row.seller_tel,
"sellerAddr": row.seller_addr,
"productName": row.product_name,
"usageDesc": row.usage_desc,
"totalQty": _f(row.total_qty),
"unit": row.unit,
"validTimes": row.valid_times,
"validFrom": row.valid_from.isoformat() if row.valid_from else None,
"validTo": row.valid_to.isoformat() if row.valid_to else None,
"policeOrg": row.police_org,
"handlerName": row.handler_name,
"policeTel": row.police_tel,
"attachmentUrls": row.attachment_urls,
"ocrResultJson": row.ocr_result_json,
"remark": row.remark,
"occupiedQty": occupied,
"remainQty": remain,
"warn": remain <= PURCHASE_CERT_WARN_TONS,
"status": status,
"transportCount": transport_count,
"transportRemainSum": transport_remain_sum,
"linkedPlanCount": linked_plan_count,
"createTime": row.create_time.isoformat() if row.create_time else None,
"updateTime": row.update_time.isoformat() if row.update_time else None,
}
def _transport_to_dict(
row: PlTransportCert,
*,
remain: float,
occupied: float,
today: Optional[date] = None,
) -> dict[str, Any]:
today = today or date.today()
status = _status_for_cert(
remain=remain,
total=_f(row.total_qty),
valid_to=row.valid_to,
today=today,
warn_tons=TRANSPORT_CERT_WARN_TONS,
)
return {
"id": row.id,
"purchaseCertId": row.purchase_cert_id,
"certNo": row.cert_no,
"issueDate": row.issue_date.isoformat() if row.issue_date else None,
"ownerName": row.owner_name,
"ownerLegal": row.owner_legal,
"ownerTel": row.owner_tel,
"ownerAddr": row.owner_addr,
"productName": row.product_name,
"packSpec": row.pack_spec,
"permitCertNos": row.permit_cert_nos,
"carrierName": row.carrier_name,
"carrierContact": row.carrier_contact,
"transportMode": row.transport_mode,
"plateNos": row.plate_nos,
"routeDesc": row.route_desc,
"consigneeName": row.consignee_name,
"totalQty": _f(row.total_qty),
"unit": row.unit,
"validTimes": row.valid_times,
"validFrom": row.valid_from.isoformat() if row.valid_from else None,
"validTo": row.valid_to.isoformat() if row.valid_to else None,
"applicantUnit": row.applicant_unit,
"policeOrg": row.police_org,
"handlerName": row.handler_name,
"policeTel": row.police_tel,
"attachmentUrls": row.attachment_urls,
"ocrResultJson": row.ocr_result_json,
"remark": row.remark,
"occupiedQty": occupied,
"remainQty": remain,
"warn": remain <= TRANSPORT_CERT_WARN_TONS,
"status": status,
"createTime": row.create_time.isoformat() if row.create_time else None,
"updateTime": row.update_time.isoformat() if row.update_time else None,
}
def _apply_purchase_fields(row: PlPurchaseCert, data: dict[str, Any]) -> None:
mapping = {
"cert_no": "cert_no",
"issue_date": "issue_date",
"buyer_name": "buyer_name",
"buyer_legal": "buyer_legal",
"buyer_tel": "buyer_tel",
"buyer_addr": "buyer_addr",
"seller_name": "seller_name",
"seller_legal": "seller_legal",
"seller_tel": "seller_tel",
"seller_addr": "seller_addr",
"product_name": "product_name",
"usage_desc": "usage_desc",
"total_qty": "total_qty",
"unit": "unit",
"valid_times": "valid_times",
"valid_from": "valid_from",
"valid_to": "valid_to",
"police_org": "police_org",
"handler_name": "handler_name",
"police_tel": "police_tel",
"attachment_urls": "attachment_urls",
"ocr_result_json": "ocr_result_json",
"remark": "remark",
"order_num": "order_num",
}
for src, attr in mapping.items():
if src in data and data[src] is not None:
setattr(row, attr, data[src])
def _apply_transport_fields(row: PlTransportCert, data: dict[str, Any]) -> None:
mapping = {
"cert_no": "cert_no",
"issue_date": "issue_date",
"owner_name": "owner_name",
"owner_legal": "owner_legal",
"owner_tel": "owner_tel",
"owner_addr": "owner_addr",
"product_name": "product_name",
"pack_spec": "pack_spec",
"permit_cert_nos": "permit_cert_nos",
"carrier_name": "carrier_name",
"carrier_contact": "carrier_contact",
"transport_mode": "transport_mode",
"plate_nos": "plate_nos",
"route_desc": "route_desc",
"consignee_name": "consignee_name",
"total_qty": "total_qty",
"unit": "unit",
"valid_times": "valid_times",
"valid_from": "valid_from",
"valid_to": "valid_to",
"applicant_unit": "applicant_unit",
"police_org": "police_org",
"handler_name": "handler_name",
"police_tel": "police_tel",
"attachment_urls": "attachment_urls",
"ocr_result_json": "ocr_result_json",
"remark": "remark",
"order_num": "order_num",
}
for src, attr in mapping.items():
if src in data and data[src] is not None:
setattr(row, attr, data[src])
def _get_purchase(db: Session, tenant_no: str, cert_id: str) -> PlPurchaseCert:
row = (
db.query(PlPurchaseCert)
.filter(
PlPurchaseCert.id == cert_id,
PlPurchaseCert.tenant_no == tenant_no,
PlPurchaseCert.del_flag == 0,
)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="购买证不存在")
return row
def list_purchase_certs(
db: Session,
*,
tenant_no: str,
q: Optional[str] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
today = date.today()
query = db.query(PlPurchaseCert).filter(
PlPurchaseCert.tenant_no == tenant_no,
PlPurchaseCert.del_flag == 0,
)
if q:
like = f"%{q.strip()}%"
trans_ids = (
db.query(PlTransportCert.purchase_cert_id)
.filter(
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.del_flag == 0,
or_(
PlTransportCert.cert_no.like(like),
PlTransportCert.plate_nos.like(like),
PlTransportCert.carrier_name.like(like),
),
)
.distinct()
)
query = query.filter(
or_(
PlPurchaseCert.cert_no.like(like),
PlPurchaseCert.product_name.like(like),
PlPurchaseCert.buyer_name.like(like),
PlPurchaseCert.seller_name.like(like),
PlPurchaseCert.id.in_(trans_ids),
)
)
rows = query.order_by(PlPurchaseCert.create_time.desc()).all()
items: list[dict[str, Any]] = []
for row in rows:
occupied = sum_occupied_qty(db, tenant_no=tenant_no, purchase_cert_id=row.id)
remain = calc_cert_remain(total=_f(row.total_qty), occupied=occupied)
transports = (
db.query(PlTransportCert)
.filter(
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.purchase_cert_id == row.id,
PlTransportCert.del_flag == 0,
)
.all()
)
t_remain_sum = 0.0
t_warn = False
plate_joined = []
for t in transports:
t_occ = sum_occupied_qty(db, tenant_no=tenant_no, transport_cert_id=t.id)
t_rem = calc_cert_remain(total=_f(t.total_qty), occupied=t_occ)
t_remain_sum += t_rem
if t_rem <= TRANSPORT_CERT_WARN_TONS:
t_warn = True
if t.plate_nos:
plate_joined.append(t.plate_nos)
linked_plan_count = (
db.query(func.count(func.distinct(PlPlanProductCertLink.plan_id)))
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.purchase_cert_id == row.id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
)
.scalar()
or 0
)
item = _purchase_to_dict(
row,
remain=remain,
occupied=occupied,
transport_count=len(transports),
transport_remain_sum=round(t_remain_sum, 3),
transport_warn=t_warn,
linked_plan_count=int(linked_plan_count),
today=today,
)
item["plateNos"] = "、".join(plate_joined) if plate_joined else ""
if status:
if status == "remain_warn" and item["status"] not in ("remain_warn",):
if not (item["warn"] and item["status"] != "exhausted"):
continue
elif status == "transport_warn" and not t_warn:
continue
elif status == "exhausted" and item["status"] != "exhausted":
continue
elif status == "expiring" and item["status"] != "expiring":
continue
elif status == "has_remain" and item["status"] not in (
"has_remain",
"remain_warn",
"transport_warn",
):
continue
items.append(item)
total = len(items)
start = max(0, (page - 1) * page_size)
page_items = items[start : start + page_size]
return {
"items": page_items,
"list": page_items,
"total": total,
"page": page,
"pageSize": page_size,
}
def get_purchase_cert_detail(db: Session, *, tenant_no: str, cert_id: str) -> dict[str, Any]:
today = date.today()
row = _get_purchase(db, tenant_no, cert_id)
occupied = sum_occupied_qty(db, tenant_no=tenant_no, purchase_cert_id=row.id)
remain = calc_cert_remain(total=_f(row.total_qty), occupied=occupied)
transports = (
db.query(PlTransportCert)
.filter(
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.purchase_cert_id == row.id,
PlTransportCert.del_flag == 0,
)
.order_by(PlTransportCert.create_time.asc())
.all()
)
trans_list = []
t_remain_sum = 0.0
t_warn = False
for t in transports:
t_occ = sum_occupied_qty(db, tenant_no=tenant_no, transport_cert_id=t.id)
t_rem = calc_cert_remain(total=_f(t.total_qty), occupied=t_occ)
t_remain_sum += t_rem
if t_rem <= TRANSPORT_CERT_WARN_TONS:
t_warn = True
trans_list.append(_transport_to_dict(t, remain=t_rem, occupied=t_occ, today=today))
linked_plan_count = (
db.query(func.count(func.distinct(PlPlanProductCertLink.plan_id)))
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.purchase_cert_id == row.id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
)
.scalar()
or 0
)
detail = _purchase_to_dict(
row,
remain=remain,
occupied=occupied,
transport_count=len(trans_list),
transport_remain_sum=round(t_remain_sum, 3),
transport_warn=t_warn,
linked_plan_count=int(linked_plan_count),
today=today,
)
detail["transList"] = trans_list
return detail
def create_purchase_cert_with_transports(
db: Session,
*,
tenant_no: str,
data: dict[str, Any],
transports: list[dict[str, Any]],
user_id: str,
dept_id: str,
) -> dict[str, Any]:
if not (data.get("cert_no") or "").strip():
raise HTTPException(status_code=400, detail="购买证编号必填")
if not (data.get("product_name") or "").strip():
raise HTTPException(status_code=400, detail="购买物品必填")
if data.get("total_qty") is None or _f(data.get("total_qty")) <= 0:
raise HTTPException(status_code=400, detail="购买证数量须大于0")
now = datetime.now()
row = PlPurchaseCert(
id=generate_uuid(),
tenant_no=tenant_no,
unit=data.get("unit") or "吨",
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
_apply_purchase_fields(row, data)
db.add(row)
for idx, tdata in enumerate(transports or []):
if not (tdata.get("cert_no") or "").strip():
raise HTTPException(status_code=400, detail=f"第{idx + 1}张运输证编号必填")
if tdata.get("total_qty") is None or _f(tdata.get("total_qty")) <= 0:
raise HTTPException(status_code=400, detail=f"第{idx + 1}张运输证数量须大于0")
permit = tdata.get("permit_cert_nos")
if not permit:
tdata = {**tdata, "permit_cert_nos": row.cert_no}
trow = PlTransportCert(
id=generate_uuid(),
purchase_cert_id=row.id,
tenant_no=tenant_no,
unit=tdata.get("unit") or "吨",
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
order_num=idx,
)
_apply_transport_fields(trow, tdata)
if not trow.product_name:
trow.product_name = row.product_name
db.add(trow)
db.commit()
return get_purchase_cert_detail(db, tenant_no=tenant_no, cert_id=row.id)
def update_purchase_cert(
db: Session,
*,
tenant_no: str,
cert_id: str,
data: dict[str, Any],
user_id: str,
) -> dict[str, Any]:
row = _get_purchase(db, tenant_no, cert_id)
_apply_purchase_fields(row, data)
row.update_by = user_id
row.update_time = datetime.now()
db.commit()
return get_purchase_cert_detail(db, tenant_no=tenant_no, cert_id=cert_id)
def soft_delete_purchase_cert(
db: Session, *, tenant_no: str, cert_id: str, user_id: str
) -> None:
row = _get_purchase(db, tenant_no, cert_id)
occupied_links = (
db.query(PlPlanProductCertLink.id)
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.purchase_cert_id == cert_id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status == LINK_STATUS_OCCUPIED,
)
.first()
)
if occupied_links:
raise HTTPException(status_code=400, detail="购买证已有占用关联,不能删除")
now = datetime.now()
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.query(PlTransportCert).filter(
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.purchase_cert_id == cert_id,
PlTransportCert.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session=False,
)
db.commit()
def add_transport_cert(
db: Session,
*,
tenant_no: str,
purchase_cert_id: str,
data: dict[str, Any],
user_id: str,
dept_id: str,
) -> dict[str, Any]:
purchase = _get_purchase(db, tenant_no, purchase_cert_id)
if not (data.get("cert_no") or "").strip():
raise HTTPException(status_code=400, detail="运输证编号必填")
if data.get("total_qty") is None or _f(data.get("total_qty")) <= 0:
raise HTTPException(status_code=400, detail="运输证数量须大于0")
if not data.get("permit_cert_nos"):
data = {**data, "permit_cert_nos": purchase.cert_no}
now = datetime.now()
trow = PlTransportCert(
id=generate_uuid(),
purchase_cert_id=purchase.id,
tenant_no=tenant_no,
unit=data.get("unit") or "吨",
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
_apply_transport_fields(trow, data)
if not trow.product_name:
trow.product_name = purchase.product_name
db.add(trow)
db.commit()
return get_purchase_cert_detail(db, tenant_no=tenant_no, cert_id=purchase_cert_id)
def list_pending_toxic_plans(db: Session, *, tenant_no: str) -> list[dict[str, Any]]:
"""待确认且含易制毒的计划摘要。"""
plans = (
db.query(PlPlan)
.filter(
PlPlan.tenant_no == tenant_no,
PlPlan.del_flag == 0,
PlPlan.follow_status == "arranged_pending_confirm",
)
.order_by(PlPlan.create_time.desc())
.all()
)
result = []
for plan in plans:
products = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.plan_id == plan.id,
PlPlanProduct.tenant_no == tenant_no,
PlPlanProduct.del_flag == 0,
)
.order_by(PlPlanProduct.order_num)
.all()
)
if not plan_has_toxic_products(db, tenant_no, products):
continue
toxic_ids = set(collect_toxic_product_ids(db, tenant_no, products))
toxic_rows = [p for p in products if p.product_id in toxic_ids]
links = (
db.query(PlPlanProductCertLink)
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.plan_id == plan.id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
)
.all()
)
link_by_pp = {lk.plan_product_id: lk for lk in links}
product_items = []
for p in toxic_rows:
lk = link_by_pp.get(p.id)
product_items.append(
{
"planProductId": p.id,
"productId": p.product_id,
"productName": p.product_name,
"quantity": _f(p.quantity),
"unit": p.unit,
"linkStatus": lk.link_status if lk else None,
"purchaseCertId": lk.purchase_cert_id if lk else None,
"transportCertId": lk.transport_cert_id if lk else None,
}
)
result.append(
{
"planId": plan.id,
"planNo": plan.plan_no,
"licensePlate": plan.license_plate,
"followStatus": plan.follow_status,
"products": product_items,
}
)
return result
def upsert_plan_product_links(
db: Session,
*,
tenant_no: str,
plan_id: str,
links: list[dict[str, Any]],
user_id: str,
dept_id: str,
) -> list[dict[str, Any]]:
plan = (
db.query(PlPlan)
.filter(
PlPlan.id == plan_id,
PlPlan.tenant_no == tenant_no,
PlPlan.del_flag == 0,
)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
if plan.follow_status != "arranged_pending_confirm":
raise HTTPException(
status_code=400, detail="仅「已安排待确认」的计划可关联购买证/运输证"
)
products = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.plan_id == plan_id,
PlPlanProduct.tenant_no == tenant_no,
PlPlanProduct.del_flag == 0,
)
.all()
)
toxic_ids = set(collect_toxic_product_ids(db, tenant_no, products))
toxic_products = {p.id: p for p in products if p.product_id in toxic_ids}
if not toxic_products:
raise HTTPException(status_code=400, detail="该计划无易制毒产品行")
# 失效旧关联
now = datetime.now()
db.query(PlPlanProductCertLink).filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.plan_id == plan_id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
).update(
{
"link_status": LINK_STATUS_VOIDED,
"update_by": user_id,
"update_time": now,
},
synchronize_session=False,
)
seen_pp: set[str] = set()
for item in links:
pp_id = item.get("plan_product_id") or item.get("planProductId")
pc_id = item.get("purchase_cert_id") or item.get("purchaseCertId")
tc_id = item.get("transport_cert_id") or item.get("transportCertId")
if not pp_id or not pc_id or not tc_id:
raise HTTPException(status_code=400, detail="关联须指定产品行、购买证与运输证")
if pp_id not in toxic_products:
raise HTTPException(status_code=400, detail=f"产品行 {pp_id} 非易制毒或不属于本计划")
if pp_id in seen_pp:
raise HTTPException(status_code=400, detail="同一产品行只能关联一对证")
seen_pp.add(pp_id)
purchase = _get_purchase(db, tenant_no, pc_id)
transport = (
db.query(PlTransportCert)
.filter(
PlTransportCert.id == tc_id,
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.del_flag == 0,
)
.first()
)
if not transport:
raise HTTPException(status_code=404, detail="运输证不存在")
if transport.purchase_cert_id != purchase.id:
raise HTTPException(status_code=400, detail="运输证须挂在所选购买证下")
product = toxic_products[pp_id]
plan_qty = _f(product.quantity)
p_occ = sum_occupied_qty(db, tenant_no=tenant_no, purchase_cert_id=purchase.id)
t_occ = sum_occupied_qty(db, tenant_no=tenant_no, transport_cert_id=transport.id)
err = validate_link_rules(
plan_qty=plan_qty,
purchase_remain=calc_cert_remain(total=_f(purchase.total_qty), occupied=p_occ),
transport_remain=calc_cert_remain(total=_f(transport.total_qty), occupied=t_occ),
plan_plate=plan.license_plate or "",
transport_plates=transport.plate_nos,
plan_product_name=product.product_name or "",
purchase_product_name=purchase.product_name or "",
purchase_cert_no=purchase.cert_no,
permit_cert_nos=transport.permit_cert_nos,
purchase_valid_from=purchase.valid_from,
purchase_valid_to=purchase.valid_to,
transport_valid_from=transport.valid_from,
transport_valid_to=transport.valid_to,
today=date.today(),
)
if err:
raise HTTPException(status_code=400, detail=err)
db.add(
PlPlanProductCertLink(
id=generate_uuid(),
plan_id=plan_id,
plan_product_id=pp_id,
purchase_cert_id=pc_id,
transport_cert_id=tc_id,
link_status=LINK_STATUS_LINKED,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
missing = set(toxic_products.keys()) - seen_pp
if missing:
raise HTTPException(
status_code=400,
detail=f"尚有 {len(missing)} 个易制毒产品行未关联购买证/运输证",
)
db.commit()
return list_pending_toxic_plans(db, tenant_no=tenant_no)
def void_links_for_plan(
db: Session,
*,
tenant_no: str,
plan_id: str,
user_id: str,
) -> int:
"""打回时将 linked/occupied 关联置为 voided(释放占用)。"""
now = datetime.now()
count = (
db.query(PlPlanProductCertLink)
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.plan_id == plan_id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
)
.update(
{
"link_status": LINK_STATUS_VOIDED,
"update_by": user_id,
"update_time": now,
},
synchronize_session=False,
)
)
return int(count or 0)
def assert_toxic_plan_links_ready_and_occupy(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
products: list[PlPlanProduct],
user_id: str,
) -> dict[str, Any]:
"""确认生效前:校验全部易制毒行已关联并通过规则,再按计划量占用。"""
toxic_ids = set(collect_toxic_product_ids(db, tenant_no, products))
toxic_products = [p for p in products if p.product_id in toxic_ids]
if not toxic_products:
raise ValueError("该计划无易制毒产品行")
links = (
db.query(PlPlanProductCertLink)
.filter(
PlPlanProductCertLink.tenant_no == tenant_no,
PlPlanProductCertLink.plan_id == plan.id,
PlPlanProductCertLink.del_flag == 0,
PlPlanProductCertLink.link_status.in_(
[LINK_STATUS_LINKED, LINK_STATUS_OCCUPIED]
),
)
.all()
)
link_by_pp = {lk.plan_product_id: lk for lk in links}
# 同一确认批次内累计占用,避免多行共用一证时超额
pending_purchase: dict[str, float] = {}
pending_transport: dict[str, float] = {}
snapshot_lines: list[dict[str, Any]] = []
today = date.today()
now = datetime.now()
for product in toxic_products:
lk = link_by_pp.get(product.id)
if not lk:
raise ValueError(f"产品「{product.product_name}」尚未关联购买证/运输证")
purchase = _get_purchase(db, tenant_no, lk.purchase_cert_id)
transport = (
db.query(PlTransportCert)
.filter(
PlTransportCert.id == lk.transport_cert_id,
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.del_flag == 0,
)
.first()
)
if not transport:
raise ValueError(f"产品「{product.product_name}」关联的运输证不存在")
plan_qty = _f(product.quantity)
base_p = sum_occupied_qty(
db, tenant_no=tenant_no, purchase_cert_id=purchase.id
) + pending_purchase.get(purchase.id, 0.0)
base_t = sum_occupied_qty(
db, tenant_no=tenant_no, transport_cert_id=transport.id
) + pending_transport.get(transport.id, 0.0)
# 若本行已是 occupied,余量计算先扣回本行旧占用再重算
if lk.link_status == LINK_STATUS_OCCUPIED and lk.occupy_plan_qty is not None:
base_p -= _f(lk.occupy_plan_qty)
base_t -= _f(lk.occupy_plan_qty)
err = validate_link_rules(
plan_qty=plan_qty,
purchase_remain=calc_cert_remain(total=_f(purchase.total_qty), occupied=base_p),
transport_remain=calc_cert_remain(
total=_f(transport.total_qty), occupied=base_t
),
plan_plate=plan.license_plate or "",
transport_plates=transport.plate_nos,
plan_product_name=product.product_name or "",
purchase_product_name=purchase.product_name or "",
purchase_cert_no=purchase.cert_no,
permit_cert_nos=transport.permit_cert_nos,
purchase_valid_from=purchase.valid_from,
purchase_valid_to=purchase.valid_to,
transport_valid_from=transport.valid_from,
transport_valid_to=transport.valid_to,
today=today,
)
if err:
raise ValueError(err)
lk.link_status = LINK_STATUS_OCCUPIED
lk.occupy_plan_qty = Decimal(str(plan_qty))
lk.update_by = user_id
lk.update_time = now
pending_purchase[purchase.id] = pending_purchase.get(purchase.id, 0.0) + plan_qty
pending_transport[transport.id] = pending_transport.get(transport.id, 0.0) + plan_qty
snapshot_lines.append(
{
"planProductId": product.id,
"productName": product.product_name,
"planQty": plan_qty,
"purchaseCertNo": purchase.cert_no,
"transportCertNo": transport.cert_no,
"purchaseRemainAfter": calc_cert_remain(
total=_f(purchase.total_qty),
occupied=base_p + plan_qty,
),
"transportRemainAfter": calc_cert_remain(
total=_f(transport.total_qty),
occupied=base_t + plan_qty,
),
}
)
return {"lines": snapshot_lines}
def get_dashboard_stats(db: Session, *, tenant_no: str) -> dict[str, Any]:
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
purchase_today = (
db.query(func.count(PlPurchaseCert.id))
.filter(
PlPurchaseCert.tenant_no == tenant_no,
PlPurchaseCert.del_flag == 0,
PlPurchaseCert.create_time >= today_start,
)
.scalar()
or 0
)
transport_today = (
db.query(func.count(PlTransportCert.id))
.filter(
PlTransportCert.tenant_no == tenant_no,
PlTransportCert.del_flag == 0,
PlTransportCert.create_time >= today_start,
)
.scalar()
or 0
)
listed = list_purchase_certs(db, tenant_no=tenant_no, page=1, page_size=10000)
purchase_warn = sum(1 for i in listed["items"] if i.get("warn"))
transport_warn = sum(
1 for i in listed["items"] if i.get("status") == "transport_warn" or (
i.get("transportRemainSum", 999) <= TRANSPORT_CERT_WARN_TONS
and i.get("transportCount", 0) > 0
)
)
pending = list_pending_toxic_plans(db, tenant_no=tenant_no)
total_qty = sum(i.get("totalQty") or 0 for i in listed["items"])
return {
"purchaseToday": int(purchase_today),
"transportToday": int(transport_today),
"purchaseWarn": purchase_warn,
"transportWarn": transport_warn,
"pendingPlanCount": len(pending),
"certCount": listed["total"],
"purchaseTotalQty": round(total_qty, 3),
"warnTons": PURCHASE_CERT_WARN_TONS,
}