forked from erp-dev/erp
412 lines
13 KiB
Python
412 lines
13 KiB
Python
"""
|
||
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,
|
||
}
|