含易制毒计划安排后进入已安排待确认,确认通过后再占合同并生成链接/地磅;新增购买证/运输证主数据、产品行关联、余量校验与 OCR 配置,并停止跟踪 api_key/app.pid。 Co-authored-by: Cursor <cursoragent@cursor.com>
1071 lines
37 KiB
Python
1071 lines
37 KiB
Python
"""易制毒购买证 / 运输证余量、关联与占用。"""
|
||
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,
|
||
}
|