锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
8.1 KiB
Python
261 lines
8.1 KiB
Python
"""基础实验交付日前一天:服务号模板提醒。"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import uuid
|
||
from datetime import date, datetime, timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.models.models import (
|
||
BizPilotApplication,
|
||
BizPilotInfusionPlan,
|
||
BizPilotInfusionSchedule,
|
||
BizPilotInfusionScheduleProduct,
|
||
BizWxNotifyLog,
|
||
SysUser,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
NOTIFY_BIZ_TYPE = "basic_experiment_delivery_remind"
|
||
CANCELLED_SCHEDULE_STATUS = "yqx"
|
||
|
||
|
||
def format_delivery_product_name(
|
||
*,
|
||
project_name: str = "",
|
||
spec_text: str = "",
|
||
package_text: str = "",
|
||
) -> str:
|
||
"""产品名称拼接:名称(规格、包装);空段省略。"""
|
||
name = (project_name or "").strip()
|
||
spec = (spec_text or "").strip()
|
||
package = (package_text or "").strip()
|
||
parts = [p for p in (spec, package) if p]
|
||
if not name and not parts:
|
||
return ""
|
||
if not parts:
|
||
return name
|
||
suffix = "、".join(parts)
|
||
if name:
|
||
return f"{name}({suffix})"
|
||
return f"({suffix})"
|
||
|
||
|
||
def _notify_enabled() -> bool:
|
||
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
|
||
return False
|
||
tid = (getattr(settings, "WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY", "") or "").strip()
|
||
return bool(tid)
|
||
|
||
|
||
def _already_success(db: Session, *, tenant_no: str, schedule_id: str, notify_date: str) -> bool:
|
||
row = (
|
||
db.query(BizWxNotifyLog)
|
||
.filter(
|
||
BizWxNotifyLog.tenant_no == tenant_no,
|
||
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
|
||
BizWxNotifyLog.biz_id == schedule_id,
|
||
BizWxNotifyLog.notify_date == notify_date,
|
||
BizWxNotifyLog.status == "success",
|
||
BizWxNotifyLog.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
return row is not None
|
||
|
||
|
||
def _write_log(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
schedule_id: str,
|
||
notify_date: str,
|
||
user_id: str,
|
||
status: str,
|
||
errcode: str = "",
|
||
errmsg: str = "",
|
||
) -> None:
|
||
now = datetime.now()
|
||
db.add(
|
||
BizWxNotifyLog(
|
||
id=str(uuid.uuid4()).replace("-", "").upper(),
|
||
biz_type=NOTIFY_BIZ_TYPE,
|
||
biz_id=schedule_id,
|
||
notify_date=notify_date,
|
||
user_id=user_id or "",
|
||
status=status,
|
||
errcode=(errcode or "")[:64] or None,
|
||
errmsg=(errmsg or "")[:512] or None,
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark="",
|
||
create_by=user_id or "",
|
||
create_dept_id="",
|
||
create_time=now,
|
||
update_by=user_id or "",
|
||
update_time=now,
|
||
del_flag=0,
|
||
)
|
||
)
|
||
|
||
|
||
def _iter_candidate_schedules(db: Session, *, delivery_day: date) -> List[Any]:
|
||
"""返回 (schedule, application, product) 元组列表。"""
|
||
day_start = datetime.combine(delivery_day, datetime.min.time())
|
||
day_end = datetime.combine(delivery_day, datetime.max.time()).replace(microsecond=0)
|
||
rows = (
|
||
db.query(BizPilotInfusionSchedule, BizPilotApplication, BizPilotInfusionScheduleProduct)
|
||
.join(
|
||
BizPilotInfusionPlan,
|
||
BizPilotInfusionPlan.id == BizPilotInfusionSchedule.infusion_plan_id,
|
||
)
|
||
.join(
|
||
BizPilotApplication,
|
||
BizPilotApplication.id == BizPilotInfusionPlan.pilot_application_id,
|
||
)
|
||
.outerjoin(
|
||
BizPilotInfusionScheduleProduct,
|
||
(
|
||
BizPilotInfusionScheduleProduct.infusion_schedule_id
|
||
== BizPilotInfusionSchedule.id
|
||
)
|
||
& (BizPilotInfusionScheduleProduct.del_flag == 0),
|
||
)
|
||
.filter(
|
||
BizPilotApplication.biz_type == "basic_experiment",
|
||
BizPilotApplication.del_flag == 0,
|
||
BizPilotInfusionPlan.del_flag == 0,
|
||
BizPilotInfusionSchedule.del_flag == 0,
|
||
BizPilotInfusionSchedule.infusion_time >= day_start,
|
||
BizPilotInfusionSchedule.infusion_time <= day_end,
|
||
BizPilotInfusionSchedule.schedule_status != CANCELLED_SCHEDULE_STATUS,
|
||
)
|
||
.order_by(BizPilotInfusionSchedule.infusion_time.asc())
|
||
.all()
|
||
)
|
||
# 一计划多产品时取首条产品行(基础实验通常一行)
|
||
seen: set = set()
|
||
out = []
|
||
for sched, app, prod in rows:
|
||
if sched.id in seen:
|
||
continue
|
||
seen.add(sched.id)
|
||
out.append((sched, app, prod))
|
||
return out
|
||
|
||
|
||
def run_delivery_remind_for_date(
|
||
db: Session,
|
||
*,
|
||
today: Optional[date] = None,
|
||
) -> Dict[str, int]:
|
||
"""扫描「明天」交付计划并发送提醒。返回计数统计。"""
|
||
stats = {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||
if not _notify_enabled():
|
||
logger.info("basic experiment delivery remind skipped: notify disabled or no template")
|
||
return stats
|
||
|
||
base = today or date.today()
|
||
delivery_day = base + timedelta(days=1)
|
||
notify_date = base.strftime("%Y-%m-%d")
|
||
|
||
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
|
||
|
||
candidates = _iter_candidate_schedules(db, delivery_day=delivery_day)
|
||
stats["candidates"] = len(candidates)
|
||
|
||
for sched, app, prod in candidates:
|
||
tenant_no = (app.tenant_no or sched.tenant_no or "").strip() or "zkxw"
|
||
schedule_id = sched.id
|
||
if _already_success(db, tenant_no=tenant_no, schedule_id=schedule_id, notify_date=notify_date):
|
||
stats["skipped"] += 1
|
||
continue
|
||
|
||
creator_id = (app.create_by or "").strip()
|
||
if not creator_id:
|
||
logger.info(
|
||
"delivery remind skip: no create_by schedule=%s app=%s",
|
||
schedule_id,
|
||
app.id,
|
||
)
|
||
stats["skipped"] += 1
|
||
continue
|
||
|
||
user = (
|
||
db.query(SysUser)
|
||
.filter(SysUser.id == creator_id, SysUser.del_flag == 0)
|
||
.first()
|
||
)
|
||
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
|
||
if not mp:
|
||
logger.info(
|
||
"delivery remind skip: no mp_openid user=%s schedule=%s",
|
||
creator_id,
|
||
schedule_id,
|
||
)
|
||
stats["skipped"] += 1
|
||
continue
|
||
|
||
product_name = format_delivery_product_name(
|
||
project_name=(prod.project_name if prod else "") or "",
|
||
spec_text=(prod.spec_text if prod else "") or (sched.spec_text or ""),
|
||
package_text=(prod.package_text if prod else "") or (sched.package_text or ""),
|
||
)
|
||
qty = 1
|
||
if prod is not None and prod.quantity is not None:
|
||
qty = int(prod.quantity)
|
||
time_text = ""
|
||
if sched.infusion_time:
|
||
time_text = sched.infusion_time.strftime("%Y-%m-%d %H:%M")
|
||
|
||
result = send_basic_experiment_delivery_remind(
|
||
mp_openid=mp,
|
||
customer_name=app.customer_name or "",
|
||
product_name=product_name,
|
||
quantity=str(qty),
|
||
contact_name=app.customer_contact or "",
|
||
time_text=time_text,
|
||
)
|
||
if result.get("success"):
|
||
_write_log(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
schedule_id=schedule_id,
|
||
notify_date=notify_date,
|
||
user_id=creator_id,
|
||
status="success",
|
||
)
|
||
stats["sent"] += 1
|
||
else:
|
||
logger.error(
|
||
"delivery remind send failed schedule=%s user=%s err=%s",
|
||
schedule_id,
|
||
creator_id,
|
||
result,
|
||
)
|
||
stats["failed"] += 1
|
||
|
||
try:
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
logger.exception("delivery remind commit failed")
|
||
raise
|
||
return stats
|
||
|
||
|
||
def run_delivery_remind_job(get_db_session) -> None:
|
||
"""APScheduler 入口。"""
|
||
db = get_db_session()
|
||
try:
|
||
stats = run_delivery_remind_for_date(db)
|
||
logger.info("basic experiment delivery remind done: %s", stats)
|
||
except Exception:
|
||
logger.exception("basic experiment delivery remind job failed")
|
||
finally:
|
||
db.close()
|