diff --git a/attendance_iot/controllers/kaoqin_test.py b/attendance_iot/controllers/kaoqin_test.py
index 7fb7a43b8..b50e7cb47 100644
--- a/attendance_iot/controllers/kaoqin_test.py
+++ b/attendance_iot/controllers/kaoqin_test.py
@@ -17,6 +17,43 @@ def remove_base64_prefix(base64_str: str) -> str:
class BusController(Controller):
+ def _sync_to_other_devices(self, employee, source_device, photo=None):
+ """
+ 将员工人脸信息同步到同公司的其他设备
+ 利用 attendance.iot.user 的 create / action_update_iot_info 自动触发 MQTT 推送
+ """
+ if not employee or not source_device:
+ return
+ # 查找同公司的其他设备
+ company = source_device.company_id or employee.company_id
+ other_devices = request.env(user=SUPERUSER_ID)['attendance.iot'].search([
+ ('company_id', '=', company.id if company else False),
+ ('id', '!=', source_device.id),
+ ])
+ if not other_devices:
+ return
+
+ # 如果 photo 不为空,顺便更新员工头像
+ if photo:
+ photo = urllib.parse.unquote(photo) if isinstance(photo, str) else photo
+ photo = remove_base64_prefix(photo)
+ employee.with_context(iot_sync=True).write({'avatar_image': photo})
+
+ for device in other_devices:
+ existed = request.env(user=SUPERUSER_ID)['attendance.iot.user'].search([
+ ('employee_id', '=', employee.id),
+ ('attendance_iot_id', '=', device.id),
+ ], limit=1)
+ if existed:
+ # 已有绑定 → 触发 MQTT editUser 推送到该设备
+ existed.with_context(iot_sync=False).action_update_iot_info()
+ else:
+ # 未绑定 → 创建绑定,create() 内部会自动触发 MQTT addUser
+ request.env(user=SUPERUSER_ID)['attendance.iot.user'].with_context(iot_sync=False).create({
+ 'employee_id': employee.id,
+ 'attendance_iot_id': device.id,
+ })
+
@route('/api/v1/addFace', methods=['POST'], type='json', auth='none', csrf=False)
def get_model_kaoqin(self):
"""
@@ -41,6 +78,8 @@ class BusController(Controller):
'attendance_iot_id': attendance_iot_id.id
})]
})
+ # 同步到同公司的其他设备
+ self._sync_to_other_devices(employee, attendance_iot_id, data.get('photo'))
_logger.info("-------------------------%s创建成功-------------------------------" % (employee.name))
return {"Result": 0, "Msg": "添加成功"}
@@ -108,6 +147,8 @@ class BusController(Controller):
'employee_id': employee.id,
'attendance_iot_id': iot_device.id,
})
+ # 同步到同公司的其他设备
+ self._sync_to_other_devices(employee, iot_device, vl_photo)
return {"Result": 0, "Msg": "添加成功"}
@route('/api/v1/uploadFaceInfo', methods=['POST'], type='json', auth='none', csrf=False)
@@ -155,6 +196,9 @@ class BusController(Controller):
photo = remove_base64_prefix(photo)
employee.with_context(iot_sync=True).write({'avatar_image': photo})
+ # 同步到同公司的其他设备(uploadFaceInfo 是设备主动发起的数据同步,同样需要推给其他设备)
+ self._sync_to_other_devices(employee, iot_device, u.get('photo'))
+
return {"Result": 0, "Msg": "更新成功"}
@http.route('/phone/attendance/list', type='http', auth="none", csrf=False, cors='*')
diff --git a/attendance_iot/models/attendance_iot_user.py b/attendance_iot/models/attendance_iot_user.py
index c6218c953..21f89b048 100644
--- a/attendance_iot/models/attendance_iot_user.py
+++ b/attendance_iot/models/attendance_iot_user.py
@@ -92,3 +92,27 @@ class AttendanceIotUser(models.Model):
'device_sn': record.attendance_iot_id.name,
'message_data': json.dumps(update_value, ensure_ascii=False)
}])
+
+ def action_batch_sync_to_all_devices(self):
+ """
+ 批量同步:将勾选员工的人脸数据,推送到该员工关联的全部设备
+ """
+ if self._context.get('iot_sync'):
+ return
+ for record in self:
+ employee = record.employee_id
+ # 找到该员工在所有设备上的绑定记录
+ all_bindings = self.search([('employee_id', '=', employee.id)])
+ if all_bindings:
+ # action_update_iot_info 内部会跳过 iot_sync 上下文
+ all_bindings.with_context(iot_sync=False).action_update_iot_info()
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '同步完成',
+ 'message': f'已向 {len(self)} 名员工关联的全部设备推送同步指令',
+ 'sticky': False,
+ 'type': 'success',
+ }
+ }
diff --git a/attendance_iot/views/attendance_iot_user_views.xml b/attendance_iot/views/attendance_iot_user_views.xml
index 387a9f28d..56f8e9526 100644
--- a/attendance_iot/views/attendance_iot_user_views.xml
+++ b/attendance_iot/views/attendance_iot_user_views.xml
@@ -4,9 +4,11 @@
attendance.iot.user.tree
attendance.iot.user
-
+
diff --git a/attendance_iot/views/attendance_iot_views.xml b/attendance_iot/views/attendance_iot_views.xml
index 544f690aa..ab3f989aa 100644
--- a/attendance_iot/views/attendance_iot_views.xml
+++ b/attendance_iot/views/attendance_iot_views.xml
@@ -6,7 +6,7 @@
@@ -19,7 +19,7 @@
-
+
diff --git a/tx_planning_resource/models/planning_slot.py b/tx_planning_resource/models/planning_slot.py
index b89e21f82..f35f55379 100644
--- a/tx_planning_resource/models/planning_slot.py
+++ b/tx_planning_resource/models/planning_slot.py
@@ -26,3 +26,26 @@ class TxPlanningSlot(models.Model):
data_list = [r.get('resource_id', {}).get('id') for r in res if r.get('resource_id', {})]
self.env['resource.resource'].search([('id', 'in', data_list)], order="sequence,name")
return res
+
+ def action_compensatory_leave(self):
+ """
+ 补休按钮 - 将当前排班转为补休记录
+ TODO: 具体业务逻辑由客户确认后补充
+ """
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': '补休',
+ 'message': f'已为 {self.resource_id.name} 创建补休申请(待实现)',
+ 'sticky': False,
+ 'type': 'success',
+ }
+ }
+
+ @api.depends('role_id', 'employee_id')
+ def _compute_template_autocomplete_ids(self):
+ domain = self._get_domain_template_slots()
+ templates = self.env['planning.slot.template'].search(domain, order='start_time', limit=15)
+ self.template_autocomplete_ids = templates + self.template_id
diff --git a/yuthon_base/models/system_logs.py b/yuthon_base/models/system_logs.py
index a7c2a165d..3c51ee424 100644
--- a/yuthon_base/models/system_logs.py
+++ b/yuthon_base/models/system_logs.py
@@ -1,11 +1,51 @@
# -*- coding: utf-8 -*-
-from odoo import models, fields
+import logging
+from odoo import models, fields, api, SUPERUSER_ID
class SystemLogs(models.Model):
_name = 'system.logs'
_description = '系统日志'
+ _order = 'date desc'
name = fields.Char(string="标题")
- date = fields.Datetime(string="时间")
+ date = fields.Datetime(string="时间", default=fields.Datetime.now)
+ level = fields.Char(string="级别")
+ logger_name = fields.Char(string="来源")
content = fields.Text(string="内容")
+
+
+class OdooDBLogHandler(logging.Handler):
+ """将日志写入 system.logs 模型"""
+
+ def emit(self, record):
+ if record.levelno < logging.INFO:
+ return
+ try:
+ import odoo
+ db_name = getattr(odoo.tools.config, 'db_name', None)
+ if not db_name:
+ return
+ db = odoo.sql_db.db_connect(db_name)
+ with db.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ env['system.logs'].create({
+ 'name': record.getMessage()[:80] if record.getMessage() else '',
+ 'date': fields.Datetime.now(),
+ 'level': record.levelname,
+ 'logger_name': record.name,
+ 'content': record.getMessage(),
+ })
+ cr.commit()
+ except Exception:
+ pass
+
+
+# 注册 Handler(只注册一次)
+_handler_key = 'odoo_db_log_handler'
+_root_logger = logging.getLogger()
+if not any(getattr(h, 'name', None) == _handler_key for h in _root_logger.handlers):
+ handler = OdooDBLogHandler()
+ handler.name = _handler_key
+ handler.setLevel(logging.INFO)
+ _root_logger.addHandler(handler)
diff --git a/yuthon_base/views/system_logs_views.xml b/yuthon_base/views/system_logs_views.xml
index c0b0ff679..f410d74fd 100644
--- a/yuthon_base/views/system_logs_views.xml
+++ b/yuthon_base/views/system_logs_views.xml
@@ -4,17 +4,36 @@
系统日志
system.logs
tree
+ {'search_default_today': 1, 'group_by': 'logger_name'}
system.logs.tree
system.logs
-
-
+
-
+
+
+
+
+
+ system.logs.search
+ system.logs
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yuthon_hr_attendance/controllers/applet_attendance.py b/yuthon_hr_attendance/controllers/applet_attendance.py
index de5e8979e..1c173b4a0 100644
--- a/yuthon_hr_attendance/controllers/applet_attendance.py
+++ b/yuthon_hr_attendance/controllers/applet_attendance.py
@@ -670,13 +670,21 @@ class AppletAttendance(Controller):
"""
id = int(kw['id'])
record = request.env['yuthon.vacation.reports'].sudo().browse(id)
- record.manage_reject()
- # 这里需要确认提交
+ # 检查是否有未办结的人力流程
+ has_pending, pending_tip = record._has_pending_hr_workflow(
+ record.employee_id.id, record.vacation_date)
+ if has_pending:
+ return json.dumps({
+ 'code': 403,
+ 'message': pending_tip,
+ 'data': None
+ })
+ record.vacation_state = 'confirmed'
return_data = {
'code': '200',
- 'message': 'OK',
+ 'message': '确认成功',
'data': id
}
return json.dumps(return_data)
diff --git a/yuthon_hr_attendance/controllers/portal.py b/yuthon_hr_attendance/controllers/portal.py
index 06a2a149a..71cf6794c 100644
--- a/yuthon_hr_attendance/controllers/portal.py
+++ b/yuthon_hr_attendance/controllers/portal.py
@@ -56,6 +56,7 @@ class CustomerPortal(portal.CustomerPortal):
users_id = int(kw.get('users_id'))
user = request.env['res.users'].sudo().browse(int(users_id))
employee_id = user.with_company(user.company_id.id).employee_id.id
+ employee = request.env['hr.employee'].sudo().browse(employee_id)
records = request.env['yuthon.vacation.reports'].sudo().search([('employee_id.id', '=', employee_id)])
data = {}
for record in records:
@@ -64,10 +65,17 @@ class CustomerPortal(portal.CustomerPortal):
if key not in data:
data[key] = []
vacation_state = '已确认' if record.vacation_state == 'confirmed' else '待确认'
+
+ # 检查是否有未办结的人力流程
+ has_pending, pending_tip = record._has_pending_hr_workflow(employee_id, record.vacation_date)
+ can_confirm = record.vacation_state != 'confirmed' and not has_pending
+
data[key].append({
'id': record.id,
'state': vacation_state,
'month': record.vacation_date.month,
+ 'can_confirm': can_confirm,
+ 'pending_tip': pending_tip if has_pending else '',
})
return json.dumps({'data': data, 'code': 200, 'message': ''})
@@ -83,8 +91,15 @@ class CustomerPortal(portal.CustomerPortal):
return f"{round(num, 1)}{unit}"
return f"{num}{unit}"
+ # 检查是否有未办结的人力流程
+ employee_id = record.employee_id.id
+ has_pending, pending_tip = record._has_pending_hr_workflow(employee_id, record.vacation_date)
+ can_confirm = record.vacation_state != 'confirmed' and not has_pending
+
data = {
'state': '已确认' if record.vacation_state == 'confirmed' else '待确认',
+ 'can_confirm': can_confirm,
+ 'pending_tip': pending_tip if has_pending else '',
'日期': record.display_date,
'应到': f"{record.should_attendance}天",
'实到': f"{record.actual_attendance}天",
@@ -118,6 +133,15 @@ class CustomerPortal(portal.CustomerPortal):
@http.route('/phone/vacation/self/status', type='http', auth="none", csrf=False, cors='*')
def phone_vacation_self_status(self, **kw):
record_id = request.env['yuthon.vacation.reports'].sudo().search([('id', '=', kw.get('id'))])
+ if not record_id:
+ return json.dumps({'code': 400, 'message': '记录不存在'})
+
+ # 检查是否有未办结的人力流程
+ has_pending, pending_tip = record_id._has_pending_hr_workflow(
+ record_id.employee_id.id, record_id.vacation_date)
+ if has_pending:
+ return json.dumps({'code': 403, 'message': pending_tip})
+
record_id.write({'vacation_state': 'confirmed'})
return json.dumps({'code': 200, 'message': 'OK'})
diff --git a/yuthon_hr_attendance/models/yuthon_vacation_reports.py b/yuthon_hr_attendance/models/yuthon_vacation_reports.py
index f8a4e8d2c..a27f8bfef 100644
--- a/yuthon_hr_attendance/models/yuthon_vacation_reports.py
+++ b/yuthon_hr_attendance/models/yuthon_vacation_reports.py
@@ -1378,7 +1378,13 @@ class YuthonVacationReports(models.Model):
('vacation_date', '=', last_month_first),
('vacation_state', '=', 'not_sent'),
])
+ processed_employees = set()
for report in report_objs:
+ if report.employee_id.id in processed_employees:
+ # 同员工已发送过通知,多余记录只确认不重复发
+ report.write({'vacation_state': 'confirmed'})
+ continue
+ processed_employees.add(report.employee_id.id)
resource_user_ids = report.employee_id.resource_calendar_id.resource_user_ids.ids
if report.employee_id.id in resource_user_ids:
report.write({'vacation_state': 'confirmed'})
@@ -1402,7 +1408,12 @@ class YuthonVacationReports(models.Model):
('vacation_date', '=', last_month_first),
('vacation_state', '=', 'not_sent'),
])
+ processed_employees = set()
for report in report_objs:
+ if report.employee_id.id in processed_employees:
+ report.write({'vacation_state': 'confirmed'})
+ continue
+ processed_employees.add(report.employee_id.id)
resource_user_ids = report.employee_id.resource_calendar_id.resource_user_ids.ids
if report.employee_id.id in resource_user_ids:
report.write({'vacation_state': 'confirmed'})
@@ -2711,6 +2722,31 @@ class YuthonVacationReports(models.Model):
}
}
+ def _has_pending_hr_workflow(self, employee_id, month_date):
+ """
+ 检查员工在指定月份是否存在未办结的人力资源相关流程(请假、加班、外出等)
+ :param employee_id: hr.employee.id
+ :param month_date: 月份任一天(用于计算当月起止)
+ :return: (bool, str) 是否有未办结流程, 提示文字
+ """
+ month_start = month_date.replace(day=1)
+ month_end = (month_start + relativedelta(months=1) - relativedelta(days=1))
+
+ # 先找到员工对应的 user
+ user = self.env['hr.employee'].sudo().browse(employee_id).user_id
+ if not user:
+ return False, ''
+ pending = self.env['soong.workflow.record'].sudo().search_count([
+ ('users_ids', 'in', user.id),
+ ('approvals_group_id.name', '=', '考勤'),
+ ('approve_state', '=', 'pending'),
+ ('workflow_happen_date', '>=', month_start),
+ ('workflow_happen_date', '<=', month_end),
+ ])
+ if pending:
+ return True, '该月考勤相关流程尚未完结,暂不允许确认'
+ return False, ''
+
class VacationLeaveLine(models.Model):
_name = 'vacation.leave.line'
_description = '年假明细'
diff --git a/yuthon_hr_attendance/views/inherit_planning_slot_views.xml b/yuthon_hr_attendance/views/inherit_planning_slot_views.xml
index 671c8d63e..afaf92c77 100644
--- a/yuthon_hr_attendance/views/inherit_planning_slot_views.xml
+++ b/yuthon_hr_attendance/views/inherit_planning_slot_views.xml
@@ -27,7 +27,11 @@
-
+
+
+
+