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 @@