diff --git a/docs/2026-01-23_summary.md b/docs/2026-01-23_summary.md index 7c63fe6..9d57ec7 100644 --- a/docs/2026-01-23_summary.md +++ b/docs/2026-01-23_summary.md @@ -31,6 +31,16 @@ - `PrintingJob.id` - `state.name` - 推进时间(取 `state_log.completed_at`) + - 发送者(默认取推进人 `completed_by` 的员工姓名/username;兜底为“系统自动发送”) - 模板位置(便于后续改文案):`flower/settings.py` - `PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE` - 开关:`PRINTING_JOB_STATE_ADVANCED_WECOM_NOTIFY_ENABLED`(默认测试环境关闭,非测试环境开启) + - 模板已增加字段:`{sender}`(发送者) + +#### 3) 重构:提取“发送最新状态到企业微信”逻辑为可复用 service(便于后续 API 化) + +- 新增:`printing/services.py` + - `send_printing_job_latest_state_wecom(printing_job_id, sender_label, ...)` + - `render_printing_job_state_markdown(...)` +- command:`printing/management/commands/wecom_notify_printing_job_latest_state.py` + - 发送者强制为“系统自动发送”(后续 API 化可传入基于 `request.user` 的 sender_label) \ No newline at end of file diff --git a/flower/settings.py b/flower/settings.py index 8566977..e5eb070 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -271,12 +271,13 @@ else: # - 默认:测试环境关闭(避免单测出网/刷屏),非测试环境开启 PRINTING_JOB_STATE_ADVANCED_WECOM_NOTIFY_ENABLED = (not TESTING) PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE = ( - "### PrintingJob 状态推进\n" + "### 印染任务状态推进\n" "\n" - "- **PrintingOrder.id**: `{printing_order_id}`\n" - "- **PrintingJob.id**: `{printing_job_id}`\n" - "- **State**: **{state_name}**\n" - "- **AdvancedAt**: `{advanced_at}`\n" + "- **印染订单ID**:`{printing_order_id}`\n" + "- **印染任务ID**:`{printing_job_id}`\n" + "- **推进状态**:**{state_name}**\n" + "- **推进时间**:`{advanced_at}`\n" + "- **发送者**:{sender}\n" ) diff --git a/printing/handlers.py b/printing/handlers.py index 8a1930f..fbcf408 100644 --- a/printing/handlers.py +++ b/printing/handlers.py @@ -203,11 +203,21 @@ def on_printing_job_state_advanced(sender, **kwargs): "- **AdvancedAt**: `{advanced_at}`\n" ), ) + sender_label = None + completed_by = kwargs.get('completed_by') + if completed_by: + emp = getattr(completed_by, 'employee', None) + sender_label = getattr(emp, 'name', None) if emp is not None else None + sender_label = sender_label or getattr(completed_by, 'username', None) or '系统自动发送' + else: + sender_label = '系统自动发送' + message = template.format( printing_order_id=str(printing_order_id) if printing_order_id is not None else '-', printing_job_id=str(printing_job_id) if printing_job_id is not None else '-', state_name=str(state_name), advanced_at=str(advanced_at), + sender=str(sender_label), ) def _send_wecom(): diff --git a/printing/management/commands/wecom_notify_printing_job_latest_state.py b/printing/management/commands/wecom_notify_printing_job_latest_state.py new file mode 100644 index 0000000..95572c4 --- /dev/null +++ b/printing/management/commands/wecom_notify_printing_job_latest_state.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from printing.services import send_printing_job_latest_state_wecom + + +class Command(BaseCommand): + help = "发送指定 PrintingJob 的“最新状态”到企业微信机器人(markdown)" + + def add_arguments(self, parser): + parser.add_argument("printing_job_id", type=int, help="PrintingJob.id") + parser.add_argument("--key", type=str, default=None, help="可选:临时覆盖 settings.WECOM_WEBHOOK_KEY") + parser.add_argument("--timeout", type=float, default=10.0, help="HTTP 超时(秒)") + parser.add_argument( + "--dry-run", + action="store_true", + help="仅输出将要发送的 markdown 内容,不实际调用企业微信 webhook(适用于无 https 出网环境)", + ) + + def handle(self, *args, **options): + printing_job_id = int(options["printing_job_id"]) + key = options.get("key") + timeout = float(options.get("timeout") or 10.0) + dry_run = bool(options.get("dry_run")) + + try: + result = send_printing_job_latest_state_wecom( + printing_job_id=printing_job_id, + sender_label="系统自动发送", + key=key, + timeout_seconds=timeout, + dry_run=dry_run, + ) + except Exception as exc: + raise CommandError(str(exc)) from exc + + if dry_run: + self.stdout.write(result["message"]) + return + + if not bool(result.get("ok")): + raise CommandError( + f"WeCom webhook 返回失败:errcode={result.get('errcode')}, errmsg={result.get('errmsg')}, raw={result.get('wecom')}" + ) + + self.stdout.write(self.style.SUCCESS(f"OK: {result.get('wecom')}")) + diff --git a/printing/services.py b/printing/services.py index e69de29..b94f4be 100644 --- a/printing/services.py +++ b/printing/services.py @@ -0,0 +1,143 @@ +""" +Printing domain services. + +This module is intended for reusable business logic that may be called by: +- management commands +- API views +- signal handlers +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings +from django.utils import timezone + +from api_v1.utils.wecom_webhook import send_wecom_webhook_message +from printing.models import PrintingJob + + +@dataclass(frozen=True) +class PrintingJobLatestState: + state_name: str + advanced_at: str + + +def _user_label(user) -> str: + """ + Format a human-readable sender label from Django User. + Prefer employee.name if available. + """ + if not user: + return "系统自动发送" + emp = getattr(user, "employee", None) + name = getattr(emp, "name", None) if emp is not None else None + if name: + return str(name) + username = getattr(user, "username", None) + if username: + return str(username) + return "系统自动发送" + + +def get_printing_job_latest_state(*, job: PrintingJob) -> PrintingJobLatestState: + """ + Latest state is defined as the latest non-cancelled StateFlowRecord + on job.business_object. + """ + if not job.business_object: + raise ValueError(f"PrintingJob 未绑定 business_object,无法获取状态:id={job.id}") + + state_log = ( + job.business_object.state_logs.filter(is_cancelled=False) + .select_related("state") + .order_by("-completed_at", "-id") + .first() + ) + if state_log is None: + return PrintingJobLatestState(state_name="未开始", advanced_at="-") + + state_name = getattr(state_log.state, "name", None) or str(state_log.state) + advanced_at_dt = state_log.completed_at + advanced_at = timezone.localtime(advanced_at_dt).strftime("%Y-%m-%d %H:%M:%S") + return PrintingJobLatestState(state_name=str(state_name), advanced_at=str(advanced_at)) + + +def render_printing_job_state_markdown( + *, + printing_order_id: str, + printing_job_id: str, + state_name: str, + advanced_at: str, + sender_label: str, +) -> str: + template = getattr( + settings, + "PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE", + ( + "### 印染任务状态推进\n" + "\n" + "- **印染订单ID**:`{printing_order_id}`\n" + "- **印染任务ID**:`{printing_job_id}`\n" + "- **推进状态**:**{state_name}**\n" + "- **推进时间**:`{advanced_at}`\n" + "- **发送者**:{sender}\n" + ), + ) + return template.format( + printing_order_id=str(printing_order_id), + printing_job_id=str(printing_job_id), + state_name=str(state_name), + advanced_at=str(advanced_at), + sender=str(sender_label or "系统自动发送"), + ) + + +def send_printing_job_latest_state_wecom( + *, + printing_job_id: int, + sender_label: str, + key: str | None = None, + timeout_seconds: float = 10.0, + dry_run: bool = False, +) -> dict: + """ + Reusable entrypoint for command / future API. + """ + job = ( + PrintingJob.objects.select_related("printing_order", "business_object") + .filter(id=int(printing_job_id)) + .first() + ) + if not job: + raise ValueError(f"PrintingJob 不存在:id={printing_job_id}") + + latest = get_printing_job_latest_state(job=job) + msg = render_printing_job_state_markdown( + printing_order_id=str(job.printing_order_id), + printing_job_id=str(job.id), + state_name=latest.state_name, + advanced_at=latest.advanced_at, + sender_label=sender_label, + ) + + if dry_run: + return {"dry_run": True, "message": msg, "state_name": latest.state_name, "advanced_at": latest.advanced_at} + + resp = send_wecom_webhook_message( + content=msg, + msgtype="markdown", + key=key, + timeout_seconds=timeout_seconds, + ) + return { + "dry_run": False, + "message": msg, + "state_name": latest.state_name, + "advanced_at": latest.advanced_at, + "wecom": resp.raw, + "ok": resp.ok, + "errcode": resp.errcode, + "errmsg": resp.errmsg, + }