!12853 添加功能

Merge pull request !12853 from 李鹏宇/dev_zhou
This commit is contained in:
李鹏宇
2026-09-16 11:09:20 +00:00
committed by Gitee
8 changed files with 218 additions and 11 deletions
+1
View File
@@ -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': [
+1
View File
@@ -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
+36 -2
View File
@@ -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',
+57 -9
View File
@@ -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,
},
@@ -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,
})
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_notice yuthon_notice model_yuthon_notice 1 1 1 1
3 access_yuthon_notice_type yuthon_notice_type model_yuthon_notice_type base.group_user 1 1 1 1
4 access_yuthon_confirm_users yuthon_confirm_users model_yuthon_confirm_users base.group_user 1 1 1 1
5 access_yuthon_notice_send_log yuthon_notice_send_log model_yuthon_notice_send_log base.group_user 1 1 1 1
6 access_yuthon_cs_tree yuthon_cs_tree model_yuthon_cs_tree base.group_user 1 1 1 1
7
@@ -9,6 +9,7 @@
<button name="reminders_notice" string="批量催办" type="object" class="oe_stat_button" icon="fa-bars"/>
<button name="all_withdrawn" string="批量撤回" type="object" icon="fa-bars" class="oe_stat_button" confirm="确认要撤回吗?"/>
<button name="reminders_notice_send" string="批量发送" type="object" icon="fa-bars" class="oe_stat_button"/>
<button name="view_send_logs" string="发送日志" type="object" class="oe_stat_button" icon="fa-history"/>
</header>
<field name="employee_id"/>
<field name="employee_number" column_invisible="1"/>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_yuthon_notice_send_log_tree" model="ir.ui.view">
<field name="name">yuthon.notice.send.log.tree</field>
<field name="model">yuthon.notice.send.log</field>
<field name="arch" type="xml">
<tree string="发送日志" decoration-danger="state == 'fail'" decoration-success="state == 'success'">
<field name="notice_id"/>
<field name="employee_id"/>
<field name="company_id" optional="hide"/>
<field name="send_type"/>
<field name="send_no"/>
<field name="send_time"/>
<field name="state"/>
<field name="msg_id"/>
<field name="note"/>
</tree>
</field>
</record>
<record id="view_yuthon_notice_send_log_form" model="ir.ui.view">
<field name="name">yuthon.notice.send.log.form</field>
<field name="model">yuthon.notice.send.log</field>
<field name="arch" type="xml">
<form string="发送日志">
<sheet>
<group>
<field name="notice_id"/>
<field name="employee_id"/>
<field name="company_id"/>
</group>
<group>
<field name="send_type"/>
<field name="send_no"/>
<field name="send_time"/>
<field name="state"/>
<field name="msg_id"/>
</group>
<group>
<field name="note" nolabel="1" placeholder="失败详情 / 接口返回"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_yuthon_notice_send_log_search" model="ir.ui.view">
<field name="name">yuthon.notice.send.log.search</field>
<field name="model">yuthon.notice.send.log</field>
<field name="arch" type="xml">
<search string="发送日志查询">
<field name="notice_id"/>
<field name="employee_id"/>
<field name="state"/>
<filter name="group_by_notice" string="按公告" context="{'group_by': 'notice_id'}"/>
<filter name="group_by_employee" string="按员工" context="{'group_by': 'employee_id'}"/>
<filter name="group_by_state" string="按结果" context="{'group_by': 'state'}"/>
<filter name="group_by_type" string="按类型" context="{'group_by': 'send_type'}"/>
</search>
</field>
</record>
<record id="yuthon_notice_send_log_action" model="ir.actions.act_window">
<field name="name">发送日志</field>
<field name="res_model">yuthon.notice.send.log</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_yuthon_notice_send_log_search"/>
</record>
</odoo>