""" 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 typing import TYPE_CHECKING from urllib.parse import urlparse from django.conf import settings from django.utils import timezone from api_v1.utils.wecom_webhook import send_wecom_webhook_message if TYPE_CHECKING: from printing.models import PrintingJob @dataclass(frozen=True) class PrintingJobLatestState: state_name: str advanced_at: str def build_printing_job_followup_url(*, printing_order_id: int | None) -> str: tmpl = getattr( settings, "PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE", "", ) tmpl = (str(tmpl or "")).strip() if not tmpl: return "" oid = printing_order_id if printing_order_id is not None else "" try: return str(tmpl).format(order_id=str(oid)) except Exception: # template mis-config should not crash critical flows return f"https://app.yuwen.cloud/workstation/production/batch-advance?orderId={oid}" def _truncate_text(s: str, max_len: int) -> str: s = s or "" if len(s) <= max_len: return s return s[: max(0, int(max_len) - 6)] + "...(截断)" def _is_http_url(s: str) -> bool: """ Minimal URL check for markdown linkify. Only treat http/https absolute URLs as linkable. """ s = (s or "").strip() if not s: return False try: p = urlparse(s) except Exception: return False return p.scheme in ("http", "https") and bool(p.netloc) def _escape_markdown_link_text(s: str) -> str: # avoid breaking markdown link text return (s or "").replace("[", r"\[").replace("]", r"\]") def render_process_params_markdown(*, params: dict, max_items: int = 60, max_value_len: int = 200) -> str: """ Render process params as markdown lines. Example: - **温度**:25 """ if not params: return "- (无)" items = [] for k in sorted(params.keys(), key=lambda x: str(x)): v = params.get(k) kk = _truncate_text(str(k), 80) v_str = "" if v is None else str(v) v_href = v_str.strip() if _is_http_url(v_href): v_disp = _escape_markdown_link_text(_truncate_text(v_href, int(max_value_len))) vv = f"[{v_disp}]({v_href})" else: vv = _truncate_text(v_str, int(max_value_len)) items.append(f"- **{kk}**:{vv}") if len(items) > int(max_items): remain = len(items) - int(max_items) items = items[: int(max_items)] + [f"- ……(其余 {remain} 项已省略)"] return "\n".join(items) 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, followup_url: str, process_params_markdown: 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" "{followup_line}" "\n" "**工序参数**:\n" "{process_params_markdown}\n" ), ) followup_url = (followup_url or "").strip() followup_line = f"- **跟进地址**:[{followup_url}]({followup_url})\n" if followup_url else "" 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 "系统自动发送"), followup_line=str(followup_line), process_params_markdown=str(process_params_markdown), ) 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. """ from printing.models import PrintingJob 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 state log for parameters state_log = ( job.business_object.state_logs.filter(is_cancelled=False) .select_related("state") .order_by("-completed_at", "-id") .first() if job.business_object else None ) if state_log is None: state_name = "未开始" advanced_at = "-" params = {} else: state_name = getattr(state_log.state, "name", None) or str(state_log.state) advanced_at = timezone.localtime(state_log.completed_at).strftime("%Y-%m-%d %H:%M:%S") params = state_log.get_all_parameters_summary(include_cancelled=False) followup_url = build_printing_job_followup_url(printing_order_id=job.printing_order_id) process_params_markdown = render_process_params_markdown(params=params) msg = render_printing_job_state_markdown( printing_order_id=str(job.printing_order_id), printing_job_id=str(job.id), state_name=str(state_name), advanced_at=str(advanced_at), sender_label=sender_label, followup_url=followup_url, process_params_markdown=process_params_markdown, ) if dry_run: return { "dry_run": True, "message": msg, "state_name": str(state_name), "advanced_at": str(advanced_at), "followup_url": str(followup_url), "process_params": params, } resp = send_wecom_webhook_message( content=msg, msgtype="markdown", key=key, timeout_seconds=timeout_seconds, ) return { "dry_run": False, "message": msg, "state_name": str(state_name), "advanced_at": str(advanced_at), "followup_url": str(followup_url), "process_params": params, "wecom": resp.raw, "ok": resp.ok, "errcode": resp.errcode, "errmsg": resp.errmsg, } def render_printing_order_created_markdown( *, printing_order_id: str, printing_order_human_id: str, created_at: str, sender_label: str, customer_name: str, fabric: str, outgoing_date: str, ) -> str: template = getattr( settings, "PRINTING_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE", ( "### 印染订单创建\n" "\n" "- **印染订单ID**:`{printing_order_id}`\n" "- **订单编号**:`{printing_order_human_id}`\n" "- **创建时间**:`{created_at}`\n" "- **发送者**:{sender}\n" "- **客户**:{customer_name}\n" "- **面料**:{fabric}\n" "- **出货日期**:`{outgoing_date}`\n" ), ) return template.format( printing_order_id=str(printing_order_id), printing_order_human_id=str(printing_order_human_id), created_at=str(created_at), sender=str(sender_label or "系统自动发送"), customer_name=str(customer_name or "-"), fabric=str(fabric or "-"), outgoing_date=str(outgoing_date or "-"), ) def render_printing_job_production_completed_markdown( *, printing_order_identifier: str, printing_job_id: str, product_name: str, unshipped_sales_items_count: str, unshipped_sales_items_quantity: str, sender_label: str, ) -> str: template = getattr( settings, "PRINTING_JOB_PRODUCTION_COMPLETED_WECOM_MARKDOWN_TEMPLATE", ( "### 印染任务已完成生产\n" "\n" "- **订单号**:`{printing_order_identifier}`\n" "- **印染任务ID**:`{printing_job_id}`\n" "- **产品名称**:{product_name}\n" "- **未出货销售品条数**:`{unshipped_sales_items_count}`\n" "- **未出货销售品数量**:`{unshipped_sales_items_quantity}`\n" "- **发送者**:{sender}\n" ), ) return template.format( printing_order_identifier=str(printing_order_identifier or "-"), printing_job_id=str(printing_job_id or "-"), product_name=str(product_name or "-"), unshipped_sales_items_count=str(unshipped_sales_items_count or "0"), unshipped_sales_items_quantity=str(unshipped_sales_items_quantity or "0"), sender=str(sender_label or "系统自动发送"), ) def send_printing_job_production_completed_wecom( *, printing_job_id: int, triggered_by_id: int | None = None, key: str | None = None, timeout_seconds: float = 10.0, dry_run: bool = False, ) -> dict: from django.contrib.auth import get_user_model from django.db.models import Sum from printing.models import PrintingJob from shipment.services import get_active_sales_items_queryset job = ( PrintingJob.objects.select_related("product", "printing_order") .filter(id=int(printing_job_id)) .first() ) if not job: raise ValueError(f"PrintingJob 不存在:id={printing_job_id}") triggered_by = None if triggered_by_id: triggered_by = get_user_model().objects.filter(id=int(triggered_by_id)).first() order = getattr(job, "printing_order", None) external_order_id = str(getattr(order, "external_order_id", "") or "").strip() printing_order_identifier = external_order_id or str(getattr(order, "id", "-") or "-") product_name = getattr(getattr(job, "product", None), "name", None) or "-" unshipped_sales_items_qs = get_active_sales_items_queryset().filter( printing_job_id=job.id, shipment__isnull=True, ) unshipped_sales_items_count = unshipped_sales_items_qs.count() unshipped_sales_items_quantity = ( unshipped_sales_items_qs.aggregate(total=Sum("quantity")).get("total") or 0 ) sender_label = _user_label(triggered_by) msg = render_printing_job_production_completed_markdown( printing_order_identifier=str(printing_order_identifier), printing_job_id=str(job.id), product_name=str(product_name), unshipped_sales_items_count=str(unshipped_sales_items_count), unshipped_sales_items_quantity=str(unshipped_sales_items_quantity), sender_label=str(sender_label), ) if dry_run: return { "dry_run": True, "message": msg, "printing_job_id": job.id, "printing_order_identifier": str(printing_order_identifier), "product_name": str(product_name), "unshipped_sales_items_count": unshipped_sales_items_count, "unshipped_sales_items_quantity": str(unshipped_sales_items_quantity), "sender_label": str(sender_label), } resp = send_wecom_webhook_message( content=msg, msgtype="markdown", key=key, timeout_seconds=timeout_seconds, ) return { "dry_run": False, "message": msg, "printing_job_id": job.id, "printing_order_identifier": str(printing_order_identifier), "product_name": str(product_name), "unshipped_sales_items_count": unshipped_sales_items_count, "unshipped_sales_items_quantity": str(unshipped_sales_items_quantity), "sender_label": str(sender_label), "wecom": resp.raw, "ok": resp.ok, "errcode": resp.errcode, "errmsg": resp.errmsg, }