diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index 94dedf1..468e335 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- -from django.db import models +from __future__ import annotations + +from django.db import models, transaction +from django.utils import timezone +from rest_framework import serializers from dvadmin.utils.models import CoreModel, table_prefix @@ -90,7 +94,10 @@ class Company(CoreModel): class SystemNoRule(CoreModel): - """单据编号规则;业务取号请用 `system_no_allocate.allocate_system_number`。""" + """单据编号规则;取号请用类方法 `allocate_system_number` / `allocate_system_numbers`。""" + + # 与前端「通用」厂区、规则维护一致 + DEFAULT_SYSTEM_NO_COMPANY_CODE = "GENERAL" RESET_CYCLE_CHOICES = ( ("yy", "YY"), @@ -101,6 +108,15 @@ class SystemNoRule(CoreModel): ("yyyymmdd", "YYYYMMDD"), ) + RESET_CYCLE_STRFTIME = { + "yy": "%y", + "yyyy": "%Y", + "yymm": "%y%m", + "yyyymm": "%Y%m", + "yymmdd": "%y%m%d", + "yyyymmdd": "%Y%m%d", + } + RULE_CODE_CHOICES = ( ("miscQTS", "杂采报价单"), ("miscRFS", "杂采询价单"), @@ -134,6 +150,129 @@ class SystemNoRule(CoreModel): last_generate_user = models.CharField(max_length=50, verbose_name="单据最后产生人", help_text="最后产生人", null=True, blank=True) last_generate_time = models.DateTimeField(verbose_name="单据最后产生时间", help_text="最后产生时间", null=True, blank=True) + @classmethod + def format_sequence_date(cls, reset_cycle: str, when=None) -> str: + fmt = cls.RESET_CYCLE_STRFTIME.get(reset_cycle or "") + if not fmt: + return "" + dt = when if when is not None else timezone.now() + return dt.strftime(fmt) + + @classmethod + def sequence_period_matches(cls, rule: SystemNoRule, seq_date: str) -> bool: + stored = (rule.sequence_date or "").strip() + return bool(stored) and stored == seq_date + + @classmethod + def _allocate_next_code_for_locked_rule(cls, rule: SystemNoRule, now) -> str: + """假定 rule 已由 select_for_update 锁定;计算下一单号并更新 rule 内存字段(未 save)。""" + seq_date = cls.format_sequence_date(rule.reset_cycle, now) + if not seq_date: + raise serializers.ValidationError("未支持的流水码重置类别") + + if not cls.sequence_period_matches(rule, seq_date): + rule.prev_sequence = 0 + new_seq = 1 + else: + nxt = int(rule.current_sequence or 0) + new_seq = nxt if nxt > 0 else 1 + if ( + rule.last_generate_time is not None + and int(rule.prev_sequence or 0) == 0 + and new_seq == 1 + ): + new_seq = 2 + + slen = max(int(rule.seq_length or 4), 1) + max_seq = 10**slen - 1 + if new_seq > max_seq: + raise serializers.ValidationError( + f"流水码已达上限(长度 {slen} 位,最大 {max_seq}),请调整规则或联系管理员" + ) + + seq_str = str(new_seq).zfill(slen) + code = "".join( + [ + rule.prefix or "", + rule.factory_code or "", + seq_date, + seq_str, + ] + ) + + rule.sequence_date = seq_date + rule.prev_sequence = new_seq + rule.current_sequence = new_seq + 1 + return code + + @classmethod + def allocate_system_number( + cls, + company_code: str, + rule_code: str, + *, + username: str | None = None, + now=None, + ) -> str: + """ + 在事务内对匹配 (company_code, rule_code) 的规则行加锁,生成单号并更新流水字段。 + :param company_code: 交易厂区(与规则表一致,常用 DEFAULT_SYSTEM_NO_COMPANY_CODE) + :param rule_code: 如 miscRFS / miscQTS + """ + codes = cls.allocate_system_numbers(company_code, rule_code, 1, username=username, now=now) + return codes[0] + + @classmethod + def allocate_system_numbers( + cls, + company_code: str, + rule_code: str, + count: int, + *, + username: str | None = None, + now=None, + ) -> list[str]: + """同一事务内连续取 count 个号(一次锁表),用于批量生成报价单等场景。""" + if count <= 0: + return [] + + if not (company_code or "").strip() or not (rule_code or "").strip(): + raise serializers.ValidationError("取号参数缺失:company_code 与 rule_code 必填") + + company_code = str(company_code).strip() + rule_code = str(rule_code).strip() + now = now or timezone.now() + + with transaction.atomic(): + rule = ( + cls.objects.select_for_update() + .filter(company_code=company_code, rule_code=rule_code) + .first() + ) + if not rule: + raise serializers.ValidationError( + f"未配置取号规则:rule_code={rule_code},company_code={company_code},请在「单据编号规则」中维护。" + ) + + codes = [] + for _ in range(count): + codes.append(cls._allocate_next_code_for_locked_rule(rule, now)) + + rule.last_generate_user = username + rule.last_generate_time = now + rule.save( + update_fields=[ + "sequence_date", + "prev_sequence", + "current_sequence", + "last_generate_user", + "last_generate_time", + "update_datetime", + ] + ) + + return codes + class Meta: db_table = table_prefix + "system_no_rules" verbose_name = "系统单号规则" @@ -143,3 +282,37 @@ class SystemNoRule(CoreModel): def __str__(self): return f"{self.rule_code}@{self.company_code}" + + +class EmailNotice(CoreModel): + STATUS_CHOICES = ( + ("pending", "pending"), + ("sending", "sending"), + ("success", "success"), + ("failed", "failed"), + ) + + subject = models.CharField(max_length=256, verbose_name="邮件主题") + body = models.TextField(null=True, blank=True, verbose_name="邮件正文") + to_emails = models.JSONField(default=list, verbose_name="收件人") + cc_emails = models.JSONField(default=list, verbose_name="抄送") + bcc_emails = models.JSONField(default=list, verbose_name="密送") + attachments = models.JSONField(default=list, verbose_name="附件") + biz_type = models.CharField(max_length=64, null=True, blank=True, verbose_name="业务类型") + biz_id = models.CharField(max_length=128, null=True, blank=True, verbose_name="业务标识") + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="pending", db_index=True, verbose_name="发送状态") + sent_at = models.DateTimeField(null=True, blank=True, verbose_name="发送时间") + message_id = models.CharField(max_length=128, null=True, blank=True, verbose_name="消息ID") + last_error = models.TextField(null=True, blank=True, verbose_name="失败原因") + payload = models.JSONField(default=dict, verbose_name="发送参数") + response = models.JSONField(default=dict, verbose_name="服务端响应") + retry_count = models.IntegerField(default=0, verbose_name="重试次数") + + class Meta: + db_table = table_prefix + "proc_email_notice" + verbose_name = "邮件通知" + verbose_name_plural = verbose_name + ordering = ("-update_datetime", "-id") + + def __str__(self) -> str: # pragma: no cover - simple repr + return self.subject diff --git a/backend/apps/pisadmin/basicinfo/system_no_allocate.py b/backend/apps/pisadmin/basicinfo/system_no_allocate.py deleted file mode 100644 index 7d2c030..0000000 --- a/backend/apps/pisadmin/basicinfo/system_no_allocate.py +++ /dev/null @@ -1,154 +0,0 @@ -# -*- coding: utf-8 -*- -""" -系统单号:基于 `SystemNoRule` 取号(与 `system_no_rules` 表一致)。 - -组装规则:prefix + factory_code + sequence_date + str(本次流水).zfill(seq_length) -示例:杂采询价单 miscRFS,prefix=RFS、厂区空、按日 yymmdd → RFS2603240001;厂区 SZ → RFSSZ2603240001。 -""" -from __future__ import annotations - -from django.db import transaction -from django.utils import timezone -from rest_framework import serializers - -from apps.pisadmin.basicinfo.models import SystemNoRule - -# 与前端「通用」厂区、SystemNoRule 维护一致 -DEFAULT_SYSTEM_NO_COMPANY_CODE = "GENERAL" - -RESET_CYCLE_STRFTIME = { - "yy": "%y", - "yyyy": "%Y", - "yymm": "%y%m", - "yyyymm": "%Y%m", - "yymmdd": "%y%m%d", - "yyyymmdd": "%Y%m%d", -} - - -def format_sequence_date(reset_cycle: str, when=None) -> str: - fmt = RESET_CYCLE_STRFTIME.get(reset_cycle or "") - if not fmt: - return "" - dt = when if when is not None else timezone.now() - return dt.strftime(fmt) - - -def sequence_period_matches(rule: SystemNoRule, seq_date: str) -> bool: - stored = (rule.sequence_date or "").strip() - return bool(stored) and stored == seq_date - - -def _allocate_next_code_for_locked_rule(rule: SystemNoRule, now) -> str: - """ - 假定 rule 已由 select_for_update 锁定;计算下一单号并更新 rule 内存字段(未 save)。 - """ - seq_date = format_sequence_date(rule.reset_cycle, now) - if not seq_date: - raise serializers.ValidationError("未支持的流水码重置类别") - - if not sequence_period_matches(rule, seq_date): - rule.prev_sequence = 0 - new_seq = 1 - else: - nxt = int(rule.current_sequence or 0) - new_seq = nxt if nxt > 0 else 1 - if ( - rule.last_generate_time is not None - and int(rule.prev_sequence or 0) == 0 - and new_seq == 1 - ): - new_seq = 2 - - slen = max(int(rule.seq_length or 4), 1) - max_seq = 10**slen - 1 - if new_seq > max_seq: - raise serializers.ValidationError( - f"流水码已达上限(长度 {slen} 位,最大 {max_seq}),请调整规则或联系管理员" - ) - - seq_str = str(new_seq).zfill(slen) - code = "".join( - [ - rule.prefix or "", - rule.factory_code or "", - seq_date, - seq_str, - ] - ) - - rule.sequence_date = seq_date - rule.prev_sequence = new_seq - rule.current_sequence = new_seq + 1 - return code - - -def allocate_system_number( - company_code: str, - rule_code: str, - *, - username: str | None = None, - now=None, -) -> str: - """ - 在事务内对匹配 (company_code, rule_code) 的规则行加锁,生成单号并更新流水字段。 - - :param company_code: 交易厂区(与规则表一致,常用 DEFAULT_SYSTEM_NO_COMPANY_CODE) - :param rule_code: 如 miscRFS / miscQTS - :param username: 写入 last_generate_user - :return: 完整单号字符串 - """ - codes = allocate_system_numbers(company_code, rule_code, 1, username=username, now=now) - return codes[0] - - -def allocate_system_numbers( - company_code: str, - rule_code: str, - count: int, - *, - username: str | None = None, - now=None, -) -> list[str]: - """ - 同一事务内连续取 `count` 个号(一次锁表),用于批量生成报价单等场景。 - """ - if count <= 0: - return [] - - if not (company_code or "").strip() or not (rule_code or "").strip(): - raise serializers.ValidationError("取号参数缺失:company_code 与 rule_code 必填") - - company_code = str(company_code).strip() - rule_code = str(rule_code).strip() - now = now or timezone.now() - - with transaction.atomic(): - rule = ( - SystemNoRule.objects.select_for_update() - .filter(company_code=company_code, rule_code=rule_code) - .first() - ) - if not rule: - raise serializers.ValidationError( - f"未配置取号规则:rule_code={rule_code},company_code={company_code},请在「单据编号规则」中维护。" - ) - - codes = [] - for _ in range(count): - codes.append(_allocate_next_code_for_locked_rule(rule, now)) - - rule.last_generate_user = username - rule.last_generate_time = now - rule.save( - update_fields=[ - "sequence_date", - "prev_sequence", - "current_sequence", - "last_generate_user", - "last_generate_time", - "update_datetime", - ] - ) - - return codes diff --git a/backend/apps/pisadmin/basicinfo/urls.py b/backend/apps/pisadmin/basicinfo/urls.py index a5037b9..29cfd90 100644 --- a/backend/apps/pisadmin/basicinfo/urls.py +++ b/backend/apps/pisadmin/basicinfo/urls.py @@ -6,6 +6,7 @@ from apps.pisadmin.basicinfo.views.currency import CurrencyViewSet from apps.pisadmin.basicinfo.views.supplier import SupplierViewSet from apps.pisadmin.basicinfo.views.company import CompanyViewSet from apps.pisadmin.basicinfo.views.system_no_rule import SystemNoRuleViewSet +from apps.pisadmin.basicinfo.views.email_utils import EmailNoticeViewSet basicinfo_url = routers.SimpleRouter() basicinfo_url.register(r'units', UnitViewSet) @@ -13,5 +14,6 @@ basicinfo_url.register(r'currencies', CurrencyViewSet) basicinfo_url.register(r'suppliers', SupplierViewSet) basicinfo_url.register(r'companies', CompanyViewSet) basicinfo_url.register(r'system_no_rules', SystemNoRuleViewSet) +basicinfo_url.register(r'email_notice', EmailNoticeViewSet) urlpatterns = [] + basicinfo_url.urls diff --git a/backend/apps/pisadmin/basicinfo/views/email_template.py b/backend/apps/pisadmin/basicinfo/views/email_template.py new file mode 100644 index 0000000..e744258 --- /dev/null +++ b/backend/apps/pisadmin/basicinfo/views/email_template.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +"""邮件正文/主题模板:按业务场景 key 渲染,供询价发布等流程调用。""" + +from __future__ import annotations + +import html +import os +from typing import Any, Callable, Dict, Tuple + +from django.conf import settings +from django.utils import timezone + +# 杂采询价单发布通知(新询价邀请) +TEMPLATE_RFS_PUBLISH = "RFS_publish" + + +def _h(text: Any) -> str: + if text is None: + return "" + return html.escape(str(text), quote=True) + + +def _for_local_display(dt: Any): + """ + 转为用于邮件展示的「本地」时间。 + + - USE_TZ=False 时 ORM 常返回 naive(即便库中是 timestamptz),应直接按业务时区墙钟时间格式化, + 不可调用 timezone.localtime(naive),否则会触发 ValueError。 + - aware 时再用 localtime 转到当前激活时区展示。 + """ + if dt is None: + return None + if timezone.is_naive(dt): + return dt + return timezone.localtime(dt) + + +def supplier_portal_base_url() -> str: + """供应商端入口基址;优先 Django settings,其次环境变量 PIS_SUPPLIER_PORTAL_URL。""" + base = getattr(settings, "PIS_SUPPLIER_PORTAL_URL", None) or os.getenv("PIS_SUPPLIER_PORTAL_URL", "") or "" + return str(base).strip().rstrip("/") + + +def build_context_rfs_publish( + inquiry: Any, + supplier_group: Dict[str, Any], + *, + purchaser_company_name: str = "", +) -> Dict[str, Any]: + """ + 组装 RFS_publish 模板变量(均为未转义纯文本,由渲染函数做 HTML 转义)。 + supplier_group:与 `_group_inquiry_suppliers` 一致的结构。 + """ + vendor_name = (supplier_group.get("supplier_name") or "").strip() or "贵司" + rfq_number = (getattr(inquiry, "inquiry_no", None) or "").strip() + title = (getattr(inquiry, "title", None) or "").strip() + + raw_part_ids = supplier_group.get("part_ids") or set() + part_ids = {str(x) for x in raw_part_ids} + lines: list[str] = [] + rfq_mgr = getattr(inquiry, "rfq_items", None) + if rfq_mgr is not None: + for row in rfq_mgr.all(): + if str(row.part_id) not in part_ids: + continue + seg = " ".join(x for x in (row.part_id, (row.product_name or "").strip()) if x).strip() + if seg: + lines.append(seg) + if not lines and part_ids: + lines = sorted(part_ids) + material_info = ";".join(lines) if lines else (title or "—") + + qd = getattr(inquiry, "quote_deadline", None) + if qd: + local_qd = _for_local_display(qd) + deadline_time = local_qd.strftime("%Y-%m-%d %H:%M") + deadline_date_subject = local_qd.strftime("%Y-%m-%d") + else: + deadline_time = "请登录系统查看" + deadline_date_subject = "待定" + + now = _for_local_display(timezone.now()) + current_date = now.strftime("%Y-%m-%d") if now else "" + + base = supplier_portal_base_url() + system_link = base if base else "" + + buyer = (getattr(inquiry, "buyer", None) or "").strip() + contact_person = buyer or "采购部" + contact_phone = "" # 询价单主表暂无采购电话字段,预留 + + pcn = (purchaser_company_name or "").strip() + if not pcn: + pcn = (getattr(inquiry, "company_code", None) or "").strip() or "我司" + + return { + "vendor_name": vendor_name, + "purchaser_company_name": pcn, + "rfq_number": rfq_number, + "material_info": material_info, + "inquiry_title": title, + "deadline_time": deadline_time, + "deadline_date_subject": deadline_date_subject, + "system_link": system_link, + "contact_person": contact_person, + "contact_phone": contact_phone, + "current_date": current_date, + } + + +def _render_rfs_publish_body(ctx: Dict[str, Any]) -> Tuple[str, str]: + """返回 (subject, html_body)。""" + rfq = _h(ctx.get("rfq_number")) + mat = ctx.get("material_info") or "" + mat_plain = str(mat) + mid = mat_plain[:120] + ("…" if len(mat_plain) > 120 else "") + subject = f"【新询价邀请】{ctx.get('rfq_number') or '—'} - {mid} - 截止:{ctx.get('deadline_date_subject') or '—'}" + + vendor = _h(ctx.get("vendor_name")) + purchaser = _h(ctx.get("purchaser_company_name")) + material_html = _h(ctx.get("material_info")) + deadline_line = _h(ctx.get("deadline_time")) + link = ctx.get("system_link") or "" + link_h = _h(link) + contact_name = _h(ctx.get("contact_person")) + phone_raw = (ctx.get("contact_phone") or "").strip() + phone = _h(phone_raw) if phone_raw else "—" + closing_date = _h(ctx.get("current_date")) + + if link: + button_block = f""" +

+ 立即前往报价 +

+

若按钮无法点击,请复制以下链接到浏览器打开:

+

{link_h}

""" + else: + button_block = """ +

系统未配置供应商门户地址(PIS_SUPPLIER_PORTAL_URL),请联系管理员获取登录方式。

""" + + body = f""" + + +

尊敬的 {vendor} 合作伙伴:

+

您好!

+

我司({purchaser})现正式发布新的采购询价单,诚邀贵司参与报价,我们非常期待与您的合作。

+ +

📋 询价单概要

+ + +

💡 如何参与报价?

+

请点击下方按钮登录系统查看详情并填写报价:

+ {button_block} + +

⚠️ 注意事项

+ + +

祝商祺!

+

{purchaser} 采购部

+

{closing_date}

+

此邮件由系统自动发送,请勿直接回复。

+""" + return subject, body + + +_RENDERERS: Dict[str, Callable[[Dict[str, Any]], Tuple[str, str]]] = { + TEMPLATE_RFS_PUBLISH: _render_rfs_publish_body, +} + + +def render_email(template_key: str, context: Dict[str, Any]) -> Tuple[str, str]: + """ + 按模板类型渲染邮件。 + :return: (subject, body);body 为 HTML 时由调用方在 EmailNotice.payload 中标记 is_html。 + """ + renderer = _RENDERERS.get(template_key) + if not renderer: + raise ValueError(f"Unknown email template: {template_key!r}") + return renderer(context) diff --git a/backend/apps/pisadmin/basicinfo/views/email_utils.py b/backend/apps/pisadmin/basicinfo/views/email_utils.py new file mode 100644 index 0000000..000618f --- /dev/null +++ b/backend/apps/pisadmin/basicinfo/views/email_utils.py @@ -0,0 +1,244 @@ +import os +import tempfile +import urllib.parse +import urllib.request +from typing import Tuple, Dict, Any, List +from rest_framework import serializers +from rest_framework.decorators import action + +from django.conf import settings +from django.core.mail import EmailMessage, get_connection +from dvadmin.utils.serializers import CustomModelSerializer +from dvadmin.utils.viewset import CustomModelViewSet +from apps.pisadmin.basicinfo.models import EmailNotice + + +def _collect_attachment_paths(raw_list: List[Any]) -> List[str]: + paths: List[str] = [] + if not isinstance(raw_list, list): + return paths + for item in raw_list: + if isinstance(item, str): + if item: + paths.append(item) + continue + if isinstance(item, dict): + candidate = item.get("file_path") or item.get("path") or item.get("filepath") + url = item.get("url") + if candidate: + paths.append(candidate) + elif url and isinstance(url, str) and url.startswith("http"): + # download remote file to temp location + try: + parsed = urllib.parse.urlparse(url) + filename = os.path.basename(parsed.path) or "attachment" + fd, temp_path = tempfile.mkstemp(prefix="mail_att_", suffix=os.path.splitext(filename)[1]) + os.close(fd) + urllib.request.urlretrieve(url, temp_path) + paths.append(temp_path) + except Exception: + # skip silently; caller can still send email without this attachment + continue + return paths + + +def send_email_notice(notice) -> Tuple[bool, Dict[str, Any]]: + """Send an email based on EmailNotice instance. + + Returns (success, details) + """ + + # 无认证中继(如内网 25 端口)允许 EMAIL_HOST_USER / EMAIL_HOST_PASSWORD 为空;Django SMTP 仅在两者均有值时 login + host = (getattr(settings, "EMAIL_HOST", None) or "").strip() + port_raw = getattr(settings, "EMAIL_PORT", None) + if not host: + return False, {"error": "邮件配置不完整:未配置 SMTP 主机 EMAIL_HOST"} + try: + port = int(port_raw) + except (TypeError, ValueError): + return False, {"error": "邮件配置不完整:EMAIL_PORT 无效"} + if not (1 <= port <= 65535): + return False, {"error": "邮件配置不完整:EMAIL_PORT 无效"} + + to_list = notice.to_emails or [] + cc_list = notice.cc_emails or [] + bcc_list = notice.bcc_emails or [] + if not to_list: + return False, {"error": "收件人为空"} + + use_ssl = getattr(settings, "EMAIL_USE_SSL", False) + use_tls = getattr(settings, "EMAIL_USE_TLS", False) + from_email = (getattr(settings, "EMAIL_FROM", None) or "").strip() or ( + (getattr(settings, "EMAIL_HOST_USER", None) or "").strip() + ) + if not from_email: + return False, {"error": "邮件配置不完整:请配置 EMAIL_FROM 或 EMAIL_HOST_USER 作为发件人"} + + try: + connection = get_connection( + fail_silently=False, + host=host, + port=port, + use_ssl=use_ssl, + use_tls=use_tls, + timeout=20, + ) + + msg = EmailMessage( + subject=notice.subject or "", + body=notice.body or "", + from_email=from_email, + to=to_list, + cc=cc_list, + bcc=bcc_list, + connection=connection, + ) + + payload = notice.payload or {} + is_html = bool(payload.get("is_html") or payload.get("is_body_html")) + if is_html: + msg.content_subtype = "html" + + temp_files: List[str] = [] + try: + paths = _collect_attachment_paths(notice.attachments) + for path in paths: + if not path: + continue + try: + msg.attach_file(path) + except Exception: + # 如果文件不可用,忽略该附件继续 + continue + if path.startswith(tempfile.gettempdir()): + temp_files.append(path) + msg.send(fail_silently=False) + finally: + for f in temp_files: + try: + os.remove(f) + except Exception: + pass + + return True, {"message": "sent"} + except Exception as exc: + return False, {"error": str(exc)} + + +class EmailNoticeSerializer(CustomModelSerializer): + to_emails = serializers.JSONField(required=False) + cc_emails = serializers.JSONField(required=False) + bcc_emails = serializers.JSONField(required=False) + attachments = serializers.JSONField(required=False) + payload = serializers.JSONField(required=False) + response = serializers.JSONField(required=False) + + class Meta: + model = EmailNotice + fields = "__all__" + read_only_fields = ["id", "create_datetime", "update_datetime", "creator", "modifier"] + + def _ensure_list(self, value, field_name): + if value is None: + return [] + if isinstance(value, str): + try: + import json + + parsed = json.loads(value) + if isinstance(parsed, list): + return parsed + except Exception: + pass + raise serializers.ValidationError(f"{field_name} must be a list") + if not isinstance(value, list): + raise serializers.ValidationError(f"{field_name} must be a list") + return value + + def _ensure_dict(self, value, field_name): + if value is None: + return {} + if isinstance(value, str): + try: + import json + + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + raise serializers.ValidationError(f"{field_name} must be a dict") + if not isinstance(value, dict): + raise serializers.ValidationError(f"{field_name} must be a dict") + return value + + def validate_to_emails(self, value): + return self._ensure_list(value, "to_emails") + + def validate_cc_emails(self, value): + return self._ensure_list(value, "cc_emails") + + def validate_bcc_emails(self, value): + return self._ensure_list(value, "bcc_emails") + + def validate_attachments(self, value): + return self._ensure_list(value, "attachments") + + def validate_payload(self, value): + return self._ensure_dict(value, "payload") + + def validate_response(self, value): + return self._ensure_dict(value, "response") + + +class EmailNoticeViewSet(CustomModelViewSet): + queryset = EmailNotice.objects.all() + serializer_class = EmailNoticeSerializer + filter_fields = ("subject", "status", "biz_type", "biz_id") + search_fields = ("subject", "biz_type", "biz_id", "last_error", "message_id") + ordering_fields = ("sent_at", "update_datetime", "create_datetime") + ordering = ("-sent_at", "-update_datetime") + + def get_queryset(self): + qs = super().get_queryset() + params = getattr(self, "request", None) + if not params: + return qs + + raw_range = self.request.query_params.getlist("sent_at") or [] + if not raw_range: + raw_value = self.request.query_params.get("sent_at") + if raw_value: + raw_range = raw_value.split(",") if "," in raw_value else raw_value.split("|") + + if len(raw_range) >= 2: + start = parse_datetime(raw_range[0].strip()) + end = parse_datetime(raw_range[1].strip()) + if start and end: + qs = qs.filter(sent_at__range=(start, end)) + + return qs + + @action(detail=True, methods=["post"], url_path="resend") + def resend(self, request, pk=None): + notice = self.get_object() + + # update retry count and mark sending + notice.retry_count = (notice.retry_count or 0) + 1 + notice.status = "sending" + notice.save(update_fields=["retry_count", "status", "update_datetime"]) + + success, detail = send_email_notice(notice) + + notice.response = detail + if success: + notice.status = "success" + notice.sent_at = timezone.now() + notice.last_error = None + else: + notice.status = "failed" + notice.last_error = detail.get("error") if isinstance(detail, dict) else str(detail) + + notice.save(update_fields=["status", "sent_at", "response", "last_error", "update_datetime"]) + + return Response({"success": success, "detail": detail}) diff --git a/backend/apps/pisadmin/basicinfo/views/system_no_rule.py b/backend/apps/pisadmin/basicinfo/views/system_no_rule.py index 4e52709..ab3ce51 100644 --- a/backend/apps/pisadmin/basicinfo/views/system_no_rule.py +++ b/backend/apps/pisadmin/basicinfo/views/system_no_rule.py @@ -5,7 +5,6 @@ from rest_framework.response import Response from django.utils import timezone from apps.pisadmin.basicinfo.models import SystemNoRule -from apps.pisadmin.basicinfo.system_no_allocate import allocate_system_number, format_sequence_date from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet @@ -60,7 +59,7 @@ class SystemNoRuleCreateUpdateSerializer(CustomModelSerializer): for k in _SEQUENCE_FIELDS_FROM_CLIENT_FORBIDDEN: validated_data.pop(k, None) rc = validated_data["reset_cycle"] - validated_data["sequence_date"] = format_sequence_date(rc) + validated_data["sequence_date"] = SystemNoRule.format_sequence_date(rc) validated_data["prev_sequence"] = 0 validated_data["current_sequence"] = 1 return super().create(validated_data) @@ -70,7 +69,7 @@ class SystemNoRuleCreateUpdateSerializer(CustomModelSerializer): validated_data.pop(k, None) new_cycle = validated_data.get("reset_cycle") if new_cycle is not None and new_cycle != instance.reset_cycle: - validated_data["sequence_date"] = format_sequence_date(new_cycle) + validated_data["sequence_date"] = SystemNoRule.format_sequence_date(new_cycle) validated_data["prev_sequence"] = 0 validated_data["current_sequence"] = 1 return super().update(instance, validated_data) @@ -88,7 +87,7 @@ class SystemNoRuleViewSet(CustomModelViewSet): @action(methods=["post"], detail=False, url_path="generate_code") def generate_code(self, request): - """生成单据号,含并发锁保证流水唯一(与 `allocate_system_number` 同源逻辑)""" + """生成单据号,含并发锁保证流水唯一(与 `SystemNoRule.allocate_system_number` 同源逻辑)""" company_code = request.data.get("company_code") rule_code = request.data.get("rule_code") @@ -97,7 +96,7 @@ class SystemNoRuleViewSet(CustomModelViewSet): username = getattr(request.user, "username", None) or getattr(request.user, "name", None) try: - code = allocate_system_number( + code = SystemNoRule.allocate_system_number( company_code, rule_code, username=username, diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 960df89..247f311 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -21,7 +21,7 @@ from .models import ( CostEstimateTemplateBody, ) from apps.pisadmin.basicinfo.models import Unit -from apps.pisadmin.basicinfo.system_no_allocate import DEFAULT_SYSTEM_NO_COMPANY_CODE, allocate_system_number +from apps.pisadmin.basicinfo.models import SystemNoRule from dvadmin.utils.serializers import CustomModelSerializer @@ -889,11 +889,11 @@ class InquirySerializer(CustomModelSerializer): def _generate_code(self, validated_data: dict) -> str: """ 按 `SystemNoRule` 取号:rule_code=miscRFS,company_code 取表单或默认「通用」厂区。 - 组装见 `allocate_system_number`(prefix + factory_code + sequence_date + 流水)。 + 组装见 `SystemNoRule.allocate_system_number`(prefix + factory_code + sequence_date + 流水)。 """ - company_code = (validated_data.get("company_code") or "").strip() or DEFAULT_SYSTEM_NO_COMPANY_CODE + company_code = (validated_data.get("company_code") or "").strip() or SystemNoRule.DEFAULT_SYSTEM_NO_COMPANY_CODE username = get_request_username(self) or None - return allocate_system_number(company_code, "miscRFS", username=username) + return SystemNoRule.allocate_system_number(company_code, "miscRFS", username=username) def _upsert_suppliers(self, inquiry: Inquiry, suppliers): """更新或创建供应商信息""" diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index cee9af3..965892f 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -6,7 +6,14 @@ from rest_framework import serializers from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse from dvadmin.utils.viewset import CustomModelViewSet -from apps.pisadmin.basicinfo.system_no_allocate import DEFAULT_SYSTEM_NO_COMPANY_CODE, allocate_system_numbers +from apps.pisadmin.basicinfo.models import Company, EmailNotice, SystemNoRule +from apps.pisadmin.basicinfo.views.email_template import ( + TEMPLATE_RFS_PUBLISH, + build_context_rfs_publish, + render_email, +) +from apps.pisadmin.basicinfo.views.email_utils import send_email_notice + from apps.pissupplier.models import ( QuotationMaster, QuotationAttachment, @@ -329,9 +336,9 @@ class InquiryViewSet(CustomModelViewSet): """ if count <= 0: return [] - company_code = (getattr(inquiry, "company_code", None) or "").strip() or DEFAULT_SYSTEM_NO_COMPANY_CODE + company_code = (getattr(inquiry, "company_code", None) or "").strip() or SystemNoRule.DEFAULT_SYSTEM_NO_COMPANY_CODE username = self._get_request_username() or None - return allocate_system_numbers( + return SystemNoRule.allocate_system_numbers( company_code, "miscQTS", count, @@ -386,6 +393,16 @@ class InquiryViewSet(CustomModelViewSet): return source_value return empty_default + @staticmethod + def _resolve_purchaser_company_name(inquiry: Inquiry) -> str: + code = (getattr(inquiry, "company_code", None) or "").strip() + if code: + row = Company.objects.filter(company_code=code).only("company_name", "company_short_name").first() + if row: + return (row.company_name or row.company_short_name or code).strip() + return code + return "" + def _group_inquiry_suppliers(self, inquiry: Inquiry): """ 按询价单供应商子表 `InquirySupplier` 汇总:同一 `supplier_code`(空代码时用行级占位)合并多料号 `part_id`, @@ -703,7 +720,8 @@ class InquiryViewSet(CustomModelViewSet): current_time = timezone.now() try: with transaction.atomic(): - self._create_supplier_quotations(instance) + self._create_supplier_quotations(instance) # 发布询价单时按供应商关联表,逐个创建对应的报价单 + self._notify_vendors_on_publish(instance) # 与子表汇总结果一致,逐供应商发送发布邮件 return self._save_status( instance, status=self.STATUS_PUBLISHED, @@ -720,6 +738,73 @@ class InquiryViewSet(CustomModelViewSet): msg = str(detail or exc) return ErrorResponse(msg=msg) + def _notify_vendors_on_publish(self, inquiry: Inquiry): + """ + 按询价单供应商子表汇总(与 `_create_supplier_quotations` 相同的 `_group_inquiry_suppliers`), + 每个供应商分组一封邮件,避免同一供应商多行子表重复发送。 + """ + supplier_groups = self._group_inquiry_suppliers(inquiry) + if not supplier_groups: + return + + purchaser_company_name = self._resolve_purchaser_company_name(inquiry) + + for vendor in supplier_groups: + if not isinstance(vendor, dict): + continue + + supplier_name = (vendor.get("supplier_name") or "").strip() + email = (vendor.get("contact_email") or "").strip() + to_list = [email] if email else [] + + ctx = build_context_rfs_publish( + inquiry, + vendor, + purchaser_company_name=purchaser_company_name, + ) + subject, body = render_email(TEMPLATE_RFS_PUBLISH, ctx) + + notice = EmailNotice.objects.create( + subject=subject, + body=body, + to_emails=to_list, + cc_emails=[], + bcc_emails=[], + attachments=[], + biz_type="inquiry", + biz_id=inquiry.inquiry_no, + status="pending", + payload={ + "template_key": TEMPLATE_RFS_PUBLISH, + "is_html": True, + "inquiry_no": inquiry.inquiry_no, + "inquiry_title": inquiry.title, + "quote_deadline": ctx.get("deadline_time"), + "supplier_name": supplier_name, + }, + ) + + if not to_list: + notice.status = "failed" + notice.last_error = "缺少供应商邮箱" + notice.save(update_fields=["status", "last_error", "update_datetime"]) + continue + + notice.status = "sending" + notice.save(update_fields=["status", "update_datetime"]) + + success, detail = send_email_notice(notice) + notice.response = detail or {} + if success: + notice.status = "success" + notice.sent_at = timezone.now() + notice.last_error = None + else: + notice.status = "failed" + notice.last_error = detail.get("error") if isinstance(detail, dict) else str(detail) + + notice.save(update_fields=["status", "sent_at", "response", "last_error", "update_datetime"]) + class InquirySupplierViewSet(CustomModelViewSet): queryset = InquirySupplier.objects.all() diff --git a/web/src/views/pisadmin/basicinfo/emailnotice/api.ts b/web/src/views/pisadmin/basicinfo/emailnotice/api.ts new file mode 100644 index 0000000..bf90d98 --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/emailnotice/api.ts @@ -0,0 +1,49 @@ +import { request } from '/@/utils/service' +import { PageQuery, AddReq, EditReq, DelReq, InfoReq } from '@fast-crud/fast-crud' + +export const apiPrefix = '/api/pisadmin/basicinfo/email_notice/' + +export function GetList(query: PageQuery) { + return request({ + url: apiPrefix, + method: 'get', + params: query + }) +} + +export function GetObj(id: InfoReq) { + return request({ + url: apiPrefix + id + '/', + method: 'get' + }) +} + +export function AddObj(obj: AddReq) { + return request({ + url: apiPrefix, + method: 'post', + data: obj + }) +} + +export function UpdateObj(obj: EditReq) { + return request({ + url: apiPrefix + obj.id + '/', + method: 'put', + data: obj + }) +} + +export function DelObj(id: DelReq) { + return request({ + url: apiPrefix + id + '/', + method: 'delete' + }) +} + +export function Resend(id: number | string) { + return request({ + url: `${apiPrefix}${id}/resend/`, + method: 'post' + }) +} diff --git a/web/src/views/pisadmin/basicinfo/emailnotice/crud.tsx b/web/src/views/pisadmin/basicinfo/emailnotice/crud.tsx new file mode 100644 index 0000000..7f929fd --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/emailnotice/crud.tsx @@ -0,0 +1,160 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import { ElMessage } from 'element-plus' +import * as api from './api' + +const statusDict = [ + { value: 'pending', label: '待发送' }, + { value: 'sending', label: '发送中' }, + { value: 'success', label: '已发送' }, + { value: 'failed', label: '发送失败' } +] + +const joinEmails = (value: unknown) => { + if (!Array.isArray(value)) return '' + return value.filter((v) => !!v).join('; ') +} + +const formatAttachments = (value: unknown) => { + if (!Array.isArray(value)) return '' + const names = value + .map((v: any) => v?.name || v?.file_name || v?.filename || v?.key || '') + .filter((v: any) => !!v) + if (names.length === 0) return value.length ? `${value.length} 个附件` : '' + return names.join('; ') +} + +export const createCrudOptions = function ({ crudExpose }: Partial): CreateCrudOptionsRet { + return { + crudOptions: { + request: { + pageRequest: async (query) => api.GetList(query) + }, + form: { + labelWidth: '120px' + }, + actionbar: { + buttons: { + add: { show: false } + } + }, + rowHandle: { + fixed: 'right', + width: 300, + buttons: { + resend: { + text: '重送', + type: 'warning', + order: 1, + show: ({ row }) => row.status !== 'sending', + async click({ row }) { + try { + const res: any = await api.Resend(row.id) + const ok = res?.success === true || res?.code === 0 + const detailMsg = + res?.detail?.error || res?.detail?.message || res?.msg || res?.message || '处理完成' + if (ok) { + ElMessage.success(detailMsg || '重送成功') + } else { + ElMessage.error(detailMsg || '重送失败') + } + crudExpose?.doRefresh?.() + } catch (e: any) { + const msg = + e?.response?.data?.detail?.error || + e?.response?.data?.error || + e?.response?.data?.msg || + e?.message || + '重送失败' + ElMessage.error(msg) + } + } + } + } + }, + columns: { + subject: { + title: '邮件主题', + type: 'text', + search: { show: true, component: { props: { placeholder: '请输入主题', clearable: true } } }, + column: { minWidth: 220, showOverflowTooltip: true } + }, + biz_type: { + title: '业务类型', + type: 'text', + search: { show: true, component: { props: { placeholder: '业务类型/模块', clearable: true } } }, + column: { minWidth: 140, showOverflowTooltip: true } + }, + biz_id: { + title: '业务标识', + type: 'text', + search: { show: true, component: { props: { placeholder: '业务单号/ID', clearable: true } } }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + to_emails: { + title: '收件人', + type: 'text', + column: { minWidth: 220, showOverflowTooltip: true, formatter: ({ value }) => joinEmails(value) } + }, + cc_emails: { + title: '抄送', + type: 'text', + column: { minWidth: 180, showOverflowTooltip: true, formatter: ({ value }) => joinEmails(value) } + }, + bcc_emails: { + title: '密送', + type: 'text', + column: { minWidth: 180, showOverflowTooltip: true, formatter: ({ value }) => joinEmails(value) } + }, + status: { + title: '发送状态', + type: 'dict-select', + dict: dict({ data: statusDict }), + search: { show: true, component: { props: { clearable: true, placeholder: '请选择状态' } } }, + column: { width: 120 } + }, + sent_at: { + title: '发送时间', + type: 'datetime', + search: { + show: true, + component: { + props: { + type: 'datetimerange', + valueFormat: 'YYYY-MM-DD HH:mm:ss', + startPlaceholder: '开始时间', + endPlaceholder: '结束时间' + } + } + }, + column: { width: 180 } + }, + attachments: { + title: '附件', + type: 'text', + column: { minWidth: 160, showOverflowTooltip: true, formatter: ({ value }) => formatAttachments(value) } + }, + last_error: { + title: '错误信息', + type: 'text', + column: { minWidth: 240, showOverflowTooltip: true } + }, + message_id: { + title: '消息ID', + type: 'text', + column: { minWidth: 180, showOverflowTooltip: true } + }, + retry_count: { + title: '重试次数', + type: 'number', + column: { width: 100 } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/basicinfo/emailnotice/index.vue b/web/src/views/pisadmin/basicinfo/emailnotice/index.vue new file mode 100644 index 0000000..103374c --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/emailnotice/index.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/views/pisadmin/basicinfo/systemno/crud.tsx b/web/src/views/pisadmin/basicinfo/systemno/crud.tsx index 81f6b82..a5198bf 100644 --- a/web/src/views/pisadmin/basicinfo/systemno/crud.tsx +++ b/web/src/views/pisadmin/basicinfo/systemno/crud.tsx @@ -19,7 +19,10 @@ const ruleCodeDict = [ { value: 'miscRFS', label: 'miscRFS (杂采询价单)' } ] -/** 交易厂区「通用」:非公司主数据,仅存于编号规则下拉;与 unique(company_code, rule_code) 兼容 */ +/** + * 交易厂区「通用」:非公司主数据,仅存于编号规则下拉;与 unique(company_code, rule_code) 兼容。 + * 取值须与后端 `SystemNoRule.DEFAULT_SYSTEM_NO_COMPANY_CODE`(`apps.pisadmin.basicinfo.models`)一致。 + */ export const SYSTEMNO_GENERAL_COMPANY_CODE = 'GENERAL' const generalCompanyOption = () => ({ diff --git a/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx index 4ab7abd..698d7e8 100644 --- a/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx @@ -461,7 +461,7 @@ const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[], headVersion } const itemsToSections = (items: any[], head?: any) => { - // 新版本弹窗里 form.version 为「下一版」预览值,但 items 仍来自上一版;过滤必须用明细实际所属版本 + // 版本变更弹窗里 form.version 为「下一版」预览值,但 items 仍来自上一版;过滤必须用明细实际所属版本 const headVerRaw = head?.__itemsSourceVersion ?? head?.version const headVer = headVerRaw != null && headVerRaw !== '' @@ -524,7 +524,7 @@ const itemsToSections = (items: any[], head?: any) => { const templateStatusUnconfirmed = (row: any) => Number(row?.status) === 0 const templateStatusConfirmed = (row: any) => Number(row?.status) === 1 -/** 提交用载荷(不含业务分流字段);初始添加与「新版本」共用结构,后者走独立 API。 */ +/** 提交用载荷(不含业务分流字段);初始添加与「版本变更」共用结构,后者走独立 API。 */ const buildCostTemplateSubmitPayload = (form: any) => { const sections = resolveSectionsForSubmit(form) const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') @@ -570,7 +570,7 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp }, async onOpened(ctx: any) { if (ctx.mode === 'add') { - // 新版本:由 openAdd 预填完整 sections/items;勿 refreshSectionsIfNeeded(会再走 replaceBuiltInFields,且易与预填冲突) + // 版本变更:由 openAdd 预填完整 sections/items;勿 refreshSectionsIfNeeded(会再走 replaceBuiltInFields,且易与预填冲突) if (ctx.form?.__newVersionSourceId != null && ctx.form.__newVersionSourceId !== '') { const vis = visibleTitles(ctx.form.procurement_category, (ctx.form.is_bom || 'Y') === 'Y') ctx.form.__visibleTitles = vis @@ -706,8 +706,8 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp } }, newVersion: { - text: '新版本', - title: '新版本', + text: '版本变更', + title: '版本变更', type: 'primary', order: 5, show: compute(({ row }) => templateStatusConfirmed(row)), @@ -752,10 +752,10 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp items: itemsStripped } }, - { title: '新版本' } + { title: '版本变更' } ) } catch (e: any) { - const msg = e?.response?.data?.msg || e?.message || '打开新版本失败' + const msg = e?.response?.data?.msg || e?.message || '版本变更失败' ElMessage.error(msg) } } diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index c6850ac..32f1c3b 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -183,7 +183,7 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, ElMessage.warning('请先维护询价单供应商名单后再确认') return } - await ElMessageBox.confirm('【确认】状态仅支持查看,不允许编辑或删除;如需修改请点击【还原】', '提示', { + await ElMessageBox.confirm('确认后,该询价单将锁定并不可再编辑。如需修改,后续需执行【还原】操作', '提示', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index f249092..6e35e68 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -417,13 +417,13 @@ const crudOptions = { click: ({ row }: any) => viewQuote(row) }, quoteNow: { - text: '编辑报价', + text: '编辑', type: 'primary', show: compute(({ row }) => isPendingQuotation(row)), click: ({ row }: any) => openQuote(row) }, editQuote: { - text: '提交报价', + text: '提交', type: 'warning', show: compute(({ row }) => isPendingQuotation(row)), click: ({ row }: any) => submitQuotationFromRow(row)