diff --git a/yuthon_notice/__manifest__.py b/yuthon_notice/__manifest__.py
index 34478b919..63d9cedaf 100644
--- a/yuthon_notice/__manifest__.py
+++ b/yuthon_notice/__manifest__.py
@@ -22,6 +22,7 @@
'views/yuthon_notice_type_views.xml',
'views/yuthon_cs_tree_views.xml',
'views/yuthon_confirm_users_views.xml',
+ 'views/yuthon_notice_send_log_views.xml',
],
'assets': {
'web.assets_backend': [
diff --git a/yuthon_notice/models/__init__.py b/yuthon_notice/models/__init__.py
index 9bc41f1da..0a2c7a4bd 100644
--- a/yuthon_notice/models/__init__.py
+++ b/yuthon_notice/models/__init__.py
@@ -3,5 +3,6 @@
from . import yuthon_notice
from . import yuthon_notice_type
from . import yuthon_confirm_users
+from . import yuthon_notice_send_log
from . import hr_department
diff --git a/yuthon_notice/models/yuthon_confirm_users.py b/yuthon_notice/models/yuthon_confirm_users.py
index 20a526146..5fbcf2c22 100644
--- a/yuthon_notice/models/yuthon_confirm_users.py
+++ b/yuthon_notice/models/yuthon_confirm_users.py
@@ -22,6 +22,23 @@ class YuthonConfirmUsers(models.Model):
department_id = fields.Many2one(related='employee_id.department_id', string='部门', store=True)
employee_number = fields.Char(related='employee_id.number', string='序号', store=True)
+ def view_send_logs(self):
+ """从确认信息列表跳转查看对应的发送日志"""
+ notice_ids = self.mapped('notice_id').ids
+ if not notice_ids:
+ ctx_notice = self.env.context.get('default_notice_id')
+ if ctx_notice:
+ notice_ids = [ctx_notice]
+ domain = [('notice_id', 'in', notice_ids)] if notice_ids else []
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': '发送日志',
+ 'res_model': 'yuthon.notice.send.log',
+ 'view_mode': 'tree,form',
+ 'domain': domain,
+ 'context': {'default_notice_id': notice_ids[0] if notice_ids else False},
+ }
+
def withdrawn(self):
if self.state == 'to_sent':
raise UserError('该通知还未发送,无法撤回')
@@ -38,15 +55,23 @@ class YuthonConfirmUsers(models.Model):
record.withdrawn()
def sync_confirm_users_notices(self):
+ SendLog = self.env['yuthon.notice.send.log']
for record in self.search([('state', '=', 'no')]):
partner_ids = record.employee_id.user_id.partner_id.id
record.with_context(lang=record.env.lang)._message_auto_subscribe_notify(partner_ids, 'yuthon_notice.message_yuthon_confirm_users_data')
url = f'https://phone.thtzjt.com/phone/Notice/{self.notice_id.id}?employee_id={record.employee_id.id}'
- self.sudo().env["wecom.apps"].sync_send_message_textcard(category='notice', touser=record.employee_id.wecom_userid, title="通知公告",
+ try:
+ result_data = self.sudo().env["wecom.apps"].sync_send_message_textcard(category='notice', touser=record.employee_id.wecom_userid, title="通知公告",
description=f"您有一条【{record.notice_id.name}】的通知公告请尽快确认", url=url, btntxt="详细信息")
+ success = bool(result_data and result_data[0])
+ msg_id = result_data[1].get('msgid') if (result_data and isinstance(result_data[1], dict)) else False
+ SendLog.log_send(record.notice_id, record.employee_id, 'sync', success, msg_id=msg_id, confirm_user=record)
+ except Exception:
+ SendLog.log_send(record.notice_id, record.employee_id, 'sync', False, note='接口调用异常', confirm_user=record)
def reminders_notice(self):
+ SendLog = self.env['yuthon.notice.send.log']
confirm_noice_ids = self.env["yuthon.confirm.users"].browse(self._context.get('active_ids', self._context.get('active_id')))
i = 0
for rec in confirm_noice_ids:
@@ -57,16 +82,19 @@ class YuthonConfirmUsers(models.Model):
# sync_send_message_textcard 返回 (success, res_dict),失败/地址异常时 success=False
if not result_data:
rec.write({'msg_id': 'ERROR: 接口未返回结果'})
+ SendLog.log_send(self.notice_id, rec.employee_id, 'reminder', False, note='接口未返回结果', confirm_user=rec)
continue
success, res_dict = result_data
res_dict = res_dict or {}
if success and res_dict.get('errmsg') == 'ok':
rec.write({'msg_id': res_dict.get('msgid')})
i += 1
+ SendLog.log_send(self.notice_id, rec.employee_id, 'reminder', True, msg_id=res_dict.get('msgid'), confirm_user=rec)
else:
errcode = res_dict.get('errcode')
errmsg = res_dict.get('errmsg') or res_dict.get('error') or '未知错误'
rec.write({'msg_id': f'ERROR {errcode}: {errmsg}'})
+ SendLog.log_send(self.notice_id, rec.employee_id, 'reminder', False, note=f'ERROR {errcode}: {errmsg}', confirm_user=rec)
url = f'https://phone.thtzjt.com/phone/Notice/{self.notice_id.id}?employee_id=298'
self.sudo().env["wecom.apps"].sync_send_message_textcard(category='notice',
touser='18562027762',
@@ -75,6 +103,7 @@ class YuthonConfirmUsers(models.Model):
url=url, btntxt="详细信息")
def reminders_notice_send(self):
+ SendLog = self.env['yuthon.notice.send.log']
confirm_noice_ids = self.env["yuthon.confirm.users"].browse(
self._context.get('active_ids', self._context.get('active_id')))
i = 0
@@ -86,10 +115,15 @@ class YuthonConfirmUsers(models.Model):
description=f"您有一条【{rec.notice_id.name}】待确认的通知公告请尽快确认",
url=url, btntxt="详细信息")
if result_data:
- res_dict = result_data[1]
+ res_dict = result_data[1] or {}
rec.write({'msg_id': res_dict.get('msgid')})
if res_dict.get('errmsg') == 'ok':
i = i + 1
+ SendLog.log_send(rec.notice_id, rec.employee_id, 'resend', True, msg_id=res_dict.get('msgid'), confirm_user=rec)
+ else:
+ errcode = res_dict.get('errcode')
+ errmsg = res_dict.get('errmsg') or res_dict.get('error') or '未知错误'
+ SendLog.log_send(rec.notice_id, rec.employee_id, 'resend', False, note=f'ERROR {errcode}: {errmsg}', confirm_user=rec)
url = f'https://phone.thtzjt.com/phone/Notice/{self.notice_id.id}?employee_id=298'
self.sudo().env["wecom.apps"].sync_send_message_textcard(category='notice',
touser='18562027762',
diff --git a/yuthon_notice/models/yuthon_notice.py b/yuthon_notice/models/yuthon_notice.py
index b93b5d725..8c8e09210 100644
--- a/yuthon_notice/models/yuthon_notice.py
+++ b/yuthon_notice/models/yuthon_notice.py
@@ -72,6 +72,18 @@ class YuthonNotice(models.Model):
publish_date = fields.Date(string='发布日期')
+ def view_send_logs(self):
+ """从公告跳转查看该公告的发送日志"""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': '发送日志',
+ 'res_model': 'yuthon.notice.send.log',
+ 'view_mode': 'tree,form',
+ 'domain': [('notice_id', '=', self.id)],
+ 'context': {'default_notice_id': self.id},
+ }
+
@api.depends_context('uid', 'company')
@api.depends('company_id')
def _compute_is_company(self):
@@ -225,17 +237,32 @@ class YuthonNotice(models.Model):
def no_sequence(self):
self.sequence = 0
- def notice_publish(self):
- """发布通知公告:给 yuthon.confirm.users 中尚未发送的记录发企微消息。
+ # 单公告单员工最大发送次数(含失败重试),超出不再发送,避免接口异常时无限重发
+ MAX_SEND_PER_EMPLOYEE = 1
- - 每人有且仅发一条:msg_id 为空才发,已发过的不重发
- - 发送成功才写回 msg_id(企微返回的msgid)并更新状态为"待确认"
- - 单条失败不影响其他记录,失败记录保持"待发送"状态
+ def notice_publish(self):
+ """发布通知公告:给 yuthon.confirm.users 中尚未成功发送的记录发企微消息。
+
+ 重复发送限制:
+ - 行锁(FOR UPDATE)防止快速重复点击造成并发重复发送
+ - 已发过消息(msg_id 非空)的记录跳过,保证每人有且仅一条成功消息
+ - 单公告单员工发送次数达到 MAX_SEND_PER_EMPLOYEE 后不再发送
+ - 每次发送(成功/失败/跳过)都写入 yuthon.notice.send.log 日志
"""
self.ensure_one()
+ SendLog = self.env['yuthon.notice.send.log']
- if self.notice_state == 'yes':
- return
+ # 行锁:序列化并发的发布调用,避免重复发送
+ self.env.cr.execute("SELECT id FROM yuthon_notice WHERE id = %s FOR UPDATE", (self.id,))
+
+ # 已发布且无待发送记录 -> 直接返回,避免重复触发
+ pending = self.env['yuthon.confirm.users'].sudo().search_count([
+ ('notice_id', '=', self.id), ('msg_id', '=', False)])
+ if self.notice_state == 'yes' and pending == 0:
+ return {
+ 'type': 'ir.actions.client', 'tag': 'display_notification',
+ 'params': {'title': '提示', 'message': '公告已发布,无可发送记录', 'type': 'info'},
+ }
self.notice_state = 'yes'
if not self.is_change_notice:
@@ -255,11 +282,25 @@ class YuthonNotice(models.Model):
sent_count = 0
fail_count = 0
+ skip_count = 0
for rec in confirm_records:
employee = rec.employee_id
if not employee or not employee.wecom_userid:
_logger.warning("公告[%s]确认记录%s跳过:员工或企微userid为空", self.name, rec.id)
+ skip_count += 1
continue
+
+ # 限制重复发送:已达最大发送次数则跳过并记录
+ tried = SendLog.search_count([
+ ('notice_id', '=', self.id), ('employee_id', '=', employee.id)])
+ if tried >= self.MAX_SEND_PER_EMPLOYEE:
+ _logger.warning("公告[%s]员工%s已达最大发送次数(%s),跳过",
+ self.name, employee.name, self.MAX_SEND_PER_EMPLOYEE)
+ skip_count += 1
+ SendLog.log_send(self, employee, 'publish', False,
+ note=f'已达最大发送次数 {self.MAX_SEND_PER_EMPLOYEE},跳过', confirm_user=rec)
+ continue
+
url = f'https://phone.thtzjt.com/phone/Notice/{self.id}?employee_id={employee.id}'
try:
result_data = self.sudo().env["wecom.apps"].sync_send_message_textcard(
@@ -273,6 +314,7 @@ class YuthonNotice(models.Model):
except Exception:
_logger.exception("公告[%s]给员工%s发送企微消息异常", self.name, employee.name)
fail_count += 1
+ SendLog.log_send(self, employee, 'publish', False, note='接口调用异常', confirm_user=rec)
continue
# 发送成功:返回元组第一项为 True 且携带 msgid,才写回 msg_id 并更新状态
@@ -282,11 +324,15 @@ class YuthonNotice(models.Model):
'state': 'no',
})
sent_count += 1
+ SendLog.log_send(self, employee, 'publish', True,
+ msg_id=result_data[1].get('msgid'), confirm_user=rec)
else:
_logger.warning("公告[%s]给员工%s发送失败,返回:%s", self.name, employee.name, result_data)
fail_count += 1
+ SendLog.log_send(self, employee, 'publish', False, note=str(result_data), confirm_user=rec)
- _logger.info("公告[%s]发布完成,成功发送%s条,失败%s条", self.name, sent_count, fail_count)
+ _logger.info("公告[%s]发布完成,成功%s,失败%s,跳过%s",
+ self.name, sent_count, fail_count, skip_count)
# 发布完毕弹窗提醒
return {
@@ -294,7 +340,9 @@ class YuthonNotice(models.Model):
'tag': 'display_notification',
'params': {
'title': '发布完毕',
- 'message': f'已成功发送 {sent_count} 条消息' + (f',{fail_count} 条失败' if fail_count else ''),
+ 'message': f'已成功发送 {sent_count} 条消息'
+ + (f',{fail_count} 条失败' if fail_count else '')
+ + (f',{skip_count} 条跳过' if skip_count else ''),
'type': 'success',
'sticky': False,
},
diff --git a/yuthon_notice/models/yuthon_notice_send_log.py b/yuthon_notice/models/yuthon_notice_send_log.py
new file mode 100644
index 000000000..757adb69c
--- /dev/null
+++ b/yuthon_notice/models/yuthon_notice_send_log.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models
+
+
+class YuthonNoticeSendLog(models.Model):
+ _name = 'yuthon.notice.send.log'
+ _description = '通知发送日志'
+ _order = 'send_time desc, id desc'
+
+ notice_id = fields.Many2one('yuthon.notice', string='公告', ondelete='cascade')
+ confirm_user_id = fields.Many2one('yuthon.confirm.users', string='确认记录', ondelete='set null')
+ employee_id = fields.Many2one('hr.employee', string='接收人')
+ send_type = fields.Selection([
+ ('publish', '发布'),
+ ('reminder', '催办'),
+ ('resend', '批量发送'),
+ ('sync', '自动同步'),
+ ], string='发送类型', default='publish')
+ send_time = fields.Datetime(string='发送时间', default=fields.Datetime.now)
+ msg_id = fields.Char(string='消息ID')
+ state = fields.Selection([('success', '成功'), ('fail', '失败')], string='结果')
+ note = fields.Text(string='详情')
+ send_no = fields.Integer(string='第几次发送', readonly=True)
+ company_id = fields.Many2one(related='employee_id.company_id', string='公司', store=True)
+
+ @api.model
+ def log_send(self, notice, employee, send_type, success, msg_id=False, note=False, confirm_user=False):
+ """记录一次发送结果,并自动计算该员工在本公告中的第几次发送。
+
+ :param notice: yuthon.notice 记录
+ :param employee: hr.employee 记录
+ :param send_type: publish/reminder/resend/sync
+ :param success: 是否发送成功
+ :param msg_id: 企微返回的 msgid
+ :param note: 失败原因/返回详情
+ :param confirm_user: yuthon.confirm.users 记录(可选)
+ """
+ existing = self.search_count([
+ ('notice_id', '=', notice.id),
+ ('employee_id', '=', employee.id),
+ ])
+ return self.sudo().create({
+ 'notice_id': notice.id,
+ 'employee_id': employee.id,
+ 'confirm_user_id': confirm_user.id if confirm_user else False,
+ 'send_type': send_type,
+ 'send_time': fields.Datetime.now(),
+ 'msg_id': msg_id or False,
+ 'state': 'success' if success else 'fail',
+ 'note': note or False,
+ 'send_no': existing + 1,
+ })
diff --git a/yuthon_notice/security/ir.model.access.csv b/yuthon_notice/security/ir.model.access.csv
index fd6f27cf3..eb2888446 100644
--- a/yuthon_notice/security/ir.model.access.csv
+++ b/yuthon_notice/security/ir.model.access.csv
@@ -2,5 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_notice,yuthon_notice,model_yuthon_notice,,1,1,1,1
access_yuthon_notice_type,yuthon_notice_type,model_yuthon_notice_type,base.group_user,1,1,1,1
access_yuthon_confirm_users,yuthon_confirm_users,model_yuthon_confirm_users,base.group_user,1,1,1,1
+access_yuthon_notice_send_log,yuthon_notice_send_log,model_yuthon_notice_send_log,base.group_user,1,1,1,1
access_yuthon_cs_tree,yuthon_cs_tree,model_yuthon_cs_tree,base.group_user,1,1,1,1
diff --git a/yuthon_notice/views/yuthon_confirm_users_views.xml b/yuthon_notice/views/yuthon_confirm_users_views.xml
index 9799c36f7..978a00170 100644
--- a/yuthon_notice/views/yuthon_confirm_users_views.xml
+++ b/yuthon_notice/views/yuthon_confirm_users_views.xml
@@ -9,6 +9,7 @@
+
diff --git a/yuthon_notice/views/yuthon_notice_send_log_views.xml b/yuthon_notice/views/yuthon_notice_send_log_views.xml
new file mode 100644
index 000000000..3e813dfe7
--- /dev/null
+++ b/yuthon_notice/views/yuthon_notice_send_log_views.xml
@@ -0,0 +1,69 @@
+
+
+
+ yuthon.notice.send.log.tree
+ yuthon.notice.send.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ yuthon.notice.send.log.form
+ yuthon.notice.send.log
+
+
+
+
+
+
+ yuthon.notice.send.log.search
+ yuthon.notice.send.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 发送日志
+ yuthon.notice.send.log
+ tree,form
+
+
+