315 lines
11 KiB
Python
315 lines
11 KiB
Python
|
|
"""批次送标申请 CRUD 与提交审批。"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from as_platform.audit.queue import reject_approval, submit_approval
|
|||
|
|
from as_platform.db.engine import session_scope
|
|||
|
|
from as_platform.db.models import Approval, BatchDelivery, Job, User
|
|||
|
|
from as_platform.integrations.delivery_ingest import validate_delivery_fields
|
|||
|
|
|
|||
|
|
EDITABLE_STATUSES = frozenset({"draft", "rejected", "ingest_failed"})
|
|||
|
|
DELETABLE_STATUSES = frozenset({"draft", "rejected", "ingest_failed"})
|
|||
|
|
RETRY_INGEST_STATUSES = frozenset({"ingest_failed", "ingesting"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _utcnow() -> datetime:
|
|||
|
|
return datetime.now(timezone.utc)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _new_delivery_id() -> str:
|
|||
|
|
return f"del-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_task(project: str, task: str | None) -> str | None:
|
|||
|
|
if project == "dms":
|
|||
|
|
return (task or "").strip() or None
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _enrich_delivery_dict(db, rec: BatchDelivery) -> dict[str, Any]:
|
|||
|
|
d = rec.to_dict()
|
|||
|
|
d["submitted_by"] = rec.submitted_by_name
|
|||
|
|
if rec.approval_id:
|
|||
|
|
ap = db.get(Approval, rec.approval_id)
|
|||
|
|
if ap:
|
|||
|
|
d["approval_status"] = ap.status
|
|||
|
|
d["job_id"] = ap.job_id
|
|||
|
|
if ap.job_id:
|
|||
|
|
job = db.get(Job, ap.job_id)
|
|||
|
|
if job:
|
|||
|
|
d["job_status"] = job.status
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_deliveries(
|
|||
|
|
*,
|
|||
|
|
status: str | None = None,
|
|||
|
|
mine_user_id: int | None = None,
|
|||
|
|
mine_editable_only: bool = False,
|
|||
|
|
offset: int = 0,
|
|||
|
|
limit: int = 20,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
q = db.query(BatchDelivery).order_by(BatchDelivery.updated_at.desc())
|
|||
|
|
if status:
|
|||
|
|
q = q.filter(BatchDelivery.status == status)
|
|||
|
|
if mine_user_id is not None:
|
|||
|
|
q = q.filter(
|
|||
|
|
(BatchDelivery.owner_user_id == mine_user_id)
|
|||
|
|
| (BatchDelivery.submitted_by_user_id == mine_user_id)
|
|||
|
|
)
|
|||
|
|
if mine_editable_only:
|
|||
|
|
q = q.filter(BatchDelivery.status.in_(("draft", "rejected", "ingest_failed")))
|
|||
|
|
total = q.count()
|
|||
|
|
rows = q.offset(max(0, offset)).limit(max(1, limit)).all()
|
|||
|
|
return {
|
|||
|
|
"items": [_enrich_delivery_dict(db, r) for r in rows],
|
|||
|
|
"total": total,
|
|||
|
|
"offset": offset,
|
|||
|
|
"limit": limit,
|
|||
|
|
"count": len(rows),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_delivery(delivery_id: str) -> dict[str, Any] | None:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
return _enrich_delivery_dict(db, rec) if rec else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_delivery(data: dict[str, Any], user: User) -> dict[str, Any]:
|
|||
|
|
project = (data.get("project") or "dms").strip()
|
|||
|
|
task = _normalize_task(project, data.get("task"))
|
|||
|
|
mode = (data.get("mode") or "").strip() or None
|
|||
|
|
batch_name = (data.get("batch_name") or "").strip()
|
|||
|
|
data_path = (data.get("data_path") or "").strip()
|
|||
|
|
|
|||
|
|
if not batch_name:
|
|||
|
|
raise ValueError("批次名必填")
|
|||
|
|
if data_path:
|
|||
|
|
err = validate_delivery_fields(
|
|||
|
|
project=project,
|
|||
|
|
task=task,
|
|||
|
|
mode=mode,
|
|||
|
|
batch_name=batch_name,
|
|||
|
|
data_path=data_path,
|
|||
|
|
)
|
|||
|
|
if err:
|
|||
|
|
raise ValueError(err)
|
|||
|
|
|
|||
|
|
with session_scope() as db:
|
|||
|
|
dup = (
|
|||
|
|
db.query(BatchDelivery)
|
|||
|
|
.filter_by(project=project, task=task, mode=mode, batch_name=batch_name)
|
|||
|
|
.filter(BatchDelivery.status.notin_(("rejected", "ingest_failed")))
|
|||
|
|
.first()
|
|||
|
|
)
|
|||
|
|
if dup:
|
|||
|
|
raise ValueError(f"已存在同批次申请: {dup.id} ({dup.status})")
|
|||
|
|
|
|||
|
|
rec = BatchDelivery(
|
|||
|
|
id=_new_delivery_id(),
|
|||
|
|
project=project,
|
|||
|
|
task=task,
|
|||
|
|
mode=mode,
|
|||
|
|
batch_name=batch_name,
|
|||
|
|
source_type=(data.get("source_type") or "").strip() or None,
|
|||
|
|
vehicle_scene=(data.get("vehicle_scene") or "").strip() or None,
|
|||
|
|
collection_start=(data.get("collection_start") or "").strip() or None,
|
|||
|
|
collection_end=(data.get("collection_end") or "").strip() or None,
|
|||
|
|
data_path=data_path,
|
|||
|
|
estimated_count=int(data["estimated_count"]) if data.get("estimated_count") not in (None, "") else None,
|
|||
|
|
remark=(data.get("remark") or "").strip() or None,
|
|||
|
|
status="draft",
|
|||
|
|
owner_user_id=data.get("owner_user_id") or user.id,
|
|||
|
|
owner_name=(data.get("owner_name") or user.name),
|
|||
|
|
submitted_by_user_id=user.id,
|
|||
|
|
submitted_by_name=user.name,
|
|||
|
|
)
|
|||
|
|
db.add(rec)
|
|||
|
|
db.flush()
|
|||
|
|
return rec.to_dict()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def update_delivery(delivery_id: str, data: dict[str, Any], user: User) -> dict[str, Any]:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
if not rec:
|
|||
|
|
raise ValueError("送标申请不存在")
|
|||
|
|
if rec.status not in EDITABLE_STATUSES:
|
|||
|
|
raise ValueError(f"当前状态不可编辑: {rec.status}")
|
|||
|
|
|
|||
|
|
project = (data.get("project") or rec.project).strip()
|
|||
|
|
task = _normalize_task(project, data.get("task") if "task" in data else rec.task)
|
|||
|
|
mode = (data.get("mode") if "mode" in data else rec.mode) or None
|
|||
|
|
if mode:
|
|||
|
|
mode = mode.strip() or None
|
|||
|
|
batch_name = (data.get("batch_name") or rec.batch_name).strip()
|
|||
|
|
data_path = (data.get("data_path") or rec.data_path).strip()
|
|||
|
|
|
|||
|
|
err = validate_delivery_fields(
|
|||
|
|
project=project,
|
|||
|
|
task=task,
|
|||
|
|
mode=mode,
|
|||
|
|
batch_name=batch_name,
|
|||
|
|
data_path=data_path,
|
|||
|
|
)
|
|||
|
|
if err:
|
|||
|
|
raise ValueError(err)
|
|||
|
|
|
|||
|
|
rec.project = project
|
|||
|
|
rec.task = task
|
|||
|
|
rec.mode = mode
|
|||
|
|
rec.batch_name = batch_name
|
|||
|
|
rec.data_path = data_path
|
|||
|
|
if "source_type" in data:
|
|||
|
|
rec.source_type = (data.get("source_type") or "").strip() or None
|
|||
|
|
if "vehicle_scene" in data:
|
|||
|
|
rec.vehicle_scene = (data.get("vehicle_scene") or "").strip() or None
|
|||
|
|
if "collection_start" in data:
|
|||
|
|
rec.collection_start = (data.get("collection_start") or "").strip() or None
|
|||
|
|
if "collection_end" in data:
|
|||
|
|
rec.collection_end = (data.get("collection_end") or "").strip() or None
|
|||
|
|
if "estimated_count" in data:
|
|||
|
|
v = data.get("estimated_count")
|
|||
|
|
rec.estimated_count = int(v) if v not in (None, "") else None
|
|||
|
|
if "remark" in data:
|
|||
|
|
rec.remark = (data.get("remark") or "").strip() or None
|
|||
|
|
if "owner_user_id" in data:
|
|||
|
|
rec.owner_user_id = data.get("owner_user_id") or rec.owner_user_id
|
|||
|
|
if "owner_name" in data:
|
|||
|
|
rec.owner_name = (data.get("owner_name") or "").strip() or rec.owner_name
|
|||
|
|
if rec.status in ("rejected", "ingest_failed"):
|
|||
|
|
rec.status = "draft"
|
|||
|
|
rec.error_message = None
|
|||
|
|
rec.updated_at = _utcnow()
|
|||
|
|
db.flush()
|
|||
|
|
return rec.to_dict()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def submit_delivery_for_review(delivery_id: str, user: User) -> dict[str, Any]:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
if not rec:
|
|||
|
|
raise ValueError("送标申请不存在")
|
|||
|
|
if rec.status not in EDITABLE_STATUSES:
|
|||
|
|
raise ValueError(f"当前状态不可提交: {rec.status}")
|
|||
|
|
|
|||
|
|
err = validate_delivery_fields(
|
|||
|
|
project=rec.project,
|
|||
|
|
task=rec.task,
|
|||
|
|
mode=rec.mode,
|
|||
|
|
batch_name=rec.batch_name,
|
|||
|
|
data_path=rec.data_path,
|
|||
|
|
)
|
|||
|
|
if err:
|
|||
|
|
raise ValueError(err)
|
|||
|
|
|
|||
|
|
params = {
|
|||
|
|
"delivery_id": rec.id,
|
|||
|
|
"project": rec.project,
|
|||
|
|
"task": rec.task,
|
|||
|
|
"mode": rec.mode,
|
|||
|
|
"batch_name": rec.batch_name,
|
|||
|
|
"data_path": rec.data_path,
|
|||
|
|
"source_type": rec.source_type,
|
|||
|
|
"vehicle_scene": rec.vehicle_scene,
|
|||
|
|
"estimated_count": rec.estimated_count,
|
|||
|
|
"remark": rec.remark,
|
|||
|
|
}
|
|||
|
|
approval = submit_approval(
|
|||
|
|
"delivery_ingest",
|
|||
|
|
params,
|
|||
|
|
submitted_by=user.name,
|
|||
|
|
submitted_by_user_id=user.id,
|
|||
|
|
note=f"数据送标入湖 {rec.batch_name}",
|
|||
|
|
)
|
|||
|
|
rec.status = "pending_review"
|
|||
|
|
rec.approval_id = approval.get("id")
|
|||
|
|
rec.submitted_by_user_id = user.id
|
|||
|
|
rec.submitted_by_name = user.name
|
|||
|
|
rec.updated_at = _utcnow()
|
|||
|
|
db.flush()
|
|||
|
|
out = rec.to_dict()
|
|||
|
|
out["approval"] = approval
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def mark_delivery_rejected_by_approval(approval_id: str) -> None:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.query(BatchDelivery).filter_by(approval_id=approval_id).first()
|
|||
|
|
if rec and rec.status == "pending_review":
|
|||
|
|
rec.status = "rejected"
|
|||
|
|
rec.updated_at = _utcnow()
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def on_approval_rejected(approval_id: str, **_: Any) -> None:
|
|||
|
|
mark_delivery_rejected_by_approval(approval_id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def mark_delivery_ingest_failed(delivery_id: str | None, approval_id: str | None, error: str) -> None:
|
|||
|
|
"""Job 失败或入湖异常时,确保台账状态为 ingest_failed(避免卡在 ingesting)。"""
|
|||
|
|
msg = (error or "")[:2000]
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = None
|
|||
|
|
if delivery_id:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
if not rec and approval_id:
|
|||
|
|
rec = db.query(BatchDelivery).filter_by(approval_id=approval_id).first()
|
|||
|
|
if not rec:
|
|||
|
|
return
|
|||
|
|
if rec.status in ("ingesting", "pending_review"):
|
|||
|
|
rec.status = "ingest_failed"
|
|||
|
|
rec.error_message = msg
|
|||
|
|
rec.updated_at = _utcnow()
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def delete_delivery(delivery_id: str, user: User) -> None:
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
if not rec:
|
|||
|
|
raise ValueError("送标申请不存在")
|
|||
|
|
if rec.status not in DELETABLE_STATUSES:
|
|||
|
|
raise ValueError(f"当前状态不可删除: {rec.status}")
|
|||
|
|
db.delete(rec)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def retry_delivery_ingest(delivery_id: str, user: User) -> dict[str, Any]:
|
|||
|
|
"""入湖失败后重新执行入湖 Job(无需重新走审核)。"""
|
|||
|
|
with session_scope() as db:
|
|||
|
|
rec = db.get(BatchDelivery, delivery_id)
|
|||
|
|
if not rec:
|
|||
|
|
raise ValueError("送标申请不存在")
|
|||
|
|
if rec.status not in RETRY_INGEST_STATUSES:
|
|||
|
|
raise ValueError(f"当前状态不可重新入湖: {rec.status}")
|
|||
|
|
err = validate_delivery_fields(
|
|||
|
|
project=rec.project,
|
|||
|
|
task=rec.task,
|
|||
|
|
mode=rec.mode,
|
|||
|
|
batch_name=rec.batch_name,
|
|||
|
|
data_path=rec.data_path,
|
|||
|
|
)
|
|||
|
|
if err:
|
|||
|
|
raise ValueError(err)
|
|||
|
|
approval_id = rec.approval_id
|
|||
|
|
rec.status = "ingesting"
|
|||
|
|
rec.error_message = None
|
|||
|
|
rec.updated_at = _utcnow()
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
from as_platform.jobs.queue import enqueue_job
|
|||
|
|
|
|||
|
|
job = enqueue_job(
|
|||
|
|
"delivery_ingest",
|
|||
|
|
{"delivery_id": delivery_id},
|
|||
|
|
approval_id=approval_id,
|
|||
|
|
async_run=True,
|
|||
|
|
)
|
|||
|
|
return {"ok": True, "job_id": job.get("id"), "delivery_id": delivery_id}
|