diff --git a/yuthon_hr_attendance/__manifest__.py b/yuthon_hr_attendance/__manifest__.py index ac2b082be..788d2bfa4 100644 --- a/yuthon_hr_attendance/__manifest__.py +++ b/yuthon_hr_attendance/__manifest__.py @@ -24,6 +24,7 @@ 'data/mail_template.xml', 'report/go_approval_report.xml', 'report/go_approval_templates.xml', + 'views/attendance_iot_import_action.xml', 'views/hr_attendance_views.xml', 'views/resource_calendar_leaves_views.xml', 'views/inherit_hr_employee.xml', @@ -82,6 +83,7 @@ 'wizard/attendance_controls_wizard.xml', 'wizard/attendance_correct_wizard.xml', 'wizard/customer_create_wizard_views.xml', + 'wizard/attendance_iot_import_wizard.xml', 'wizard/vacation_put_wizard_views.xml', 'wizard/vacation_reports_wizard.xml', 'wizard/vacation_confirm_wizard_views.xml', diff --git a/yuthon_hr_attendance/models/hr_attendance_detail_wechat.py b/yuthon_hr_attendance/models/hr_attendance_detail_wechat.py index d36524b4c..b59155cdc 100644 --- a/yuthon_hr_attendance/models/hr_attendance_detail_wechat.py +++ b/yuthon_hr_attendance/models/hr_attendance_detail_wechat.py @@ -98,6 +98,9 @@ class HrAttendanceDetail(models.Model): @api.model def create(self, vals): record = super(HrAttendanceDetail, self).create(vals) + # 批量导入时跳过企业微信通知 + if self.env.context.get('skip_iot_notify'): + return record if record.employee_id.name not in ['Administrator']: resource_user_ids = record.employee_id.resource_calendar_id.resource_user_ids.ids is_resource_user = True if record.employee_id.id in resource_user_ids else False diff --git a/yuthon_hr_attendance/security/ir.model.access.csv b/yuthon_hr_attendance/security/ir.model.access.csv index 3259b634b..557aa19b2 100644 --- a/yuthon_hr_attendance/security/ir.model.access.csv +++ b/yuthon_hr_attendance/security/ir.model.access.csv @@ -103,4 +103,6 @@ access_overtime_carryover_line,overtime.carryover.line,model_overtime_carryover_ access_yuthon_meal_line,yuthon.meal.line,model_yuthon_meal_line,base.group_user,1,1,1,1 access_vacation_meal_active_wizard,vacation.meal.active.wizard,model_vacation_meal_active_wizard,base.group_user,1,1,1,1 access_vacation_overtime_active_wizard,vacation.overtime.active.wizard,model_vacation_overtime_active_wizard,base.group_user,1,1,1,1 -access_customer_create_wizard,customer.create.wizard,model_customer_create_wizard,base.group_user,1,1,1,1 \ No newline at end of file +access_customer_create_wizard,customer.create.wizard,model_customer_create_wizard,base.group_user,1,1,1,1 +access_attendance_iot_import_wizard,attendance.iot.import.wizard,model_attendance_iot_import_wizard,base.group_user,1,1,1,1 +access_attendance_iot_import_preview_line,attendance.iot.import.preview.line,model_attendance_iot_import_preview_line,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/yuthon_hr_attendance/views/attendance_iot_import_action.xml b/yuthon_hr_attendance/views/attendance_iot_import_action.xml new file mode 100644 index 000000000..eff639b76 --- /dev/null +++ b/yuthon_hr_attendance/views/attendance_iot_import_action.xml @@ -0,0 +1,10 @@ + + + + + 导入考勤机日志 + attendance.iot.import.wizard + form + new + + diff --git a/yuthon_hr_attendance/views/hr_attendance_detail_wechat_views.xml b/yuthon_hr_attendance/views/hr_attendance_detail_wechat_views.xml index 96473ac9a..ed68ee51b 100644 --- a/yuthon_hr_attendance/views/hr_attendance_detail_wechat_views.xml +++ b/yuthon_hr_attendance/views/hr_attendance_detail_wechat_views.xml @@ -31,9 +31,10 @@ hr.attendance.detail - - - +
+
diff --git a/yuthon_hr_attendance/wizard/__init__.py b/yuthon_hr_attendance/wizard/__init__.py index 3091dcb00..f65d0f007 100644 --- a/yuthon_hr_attendance/wizard/__init__.py +++ b/yuthon_hr_attendance/wizard/__init__.py @@ -15,4 +15,5 @@ from . import yuthon_vacation_import_wizard from . import overtime_change_wizard from . import vacation_meal_active_wizard from . import vacation_overtime_active_wizard -from . import customer_create_wizard \ No newline at end of file +from . import customer_create_wizard +from . import attendance_iot_import_wizard \ No newline at end of file diff --git a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py new file mode 100644 index 000000000..2d74ccd1c --- /dev/null +++ b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +import base64 +import csv +import io +import logging +from datetime import datetime, timedelta + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class AttendanceIotImportWizard(models.TransientModel): + _name = "attendance.iot.import.wizard" + _description = "考勤机日志导入向导" + + log_file = fields.Binary( + string="CSV 导入文件", required=True, + help="上传从日志解析生成的 CSV 文件(包含 employee_id, checkin_time, source, attendance_iot_id, remark 列)" + ) + log_filename = fields.Char(string="文件名") + note = fields.Text(string="导入备注", default="日志补录") + preview_line_ids = fields.One2many( + 'attendance.iot.import.preview.line', + 'wizard_id', + string="预览数据", + readonly=True + ) + state = fields.Selection([ + ('upload', '上传文件'), + ('preview', '预览确认'), + ('done', '导入完成'), + ], default='upload', string="状态") + + total_found = fields.Integer(string="解析到记录数", readonly=True) + total_imported = fields.Integer(string="成功导入数", readonly=True) + total_skipped = fields.Integer(string="跳过数", readonly=True) + error_message = fields.Text(string="错误信息", readonly=True) + + def action_parse(self): + """解析上传的 CSV 文件""" + self.ensure_one() + if not self.log_file: + raise UserError(_("请先上传 CSV 文件!")) + + # 解码文件内容(支持 utf-8 和 utf-8-sig/BOM) + try: + content = base64.b64decode(self.log_file).decode('utf-8-sig') + except Exception as e: + raise UserError(_("文件解码失败: %s") % str(e)) + + # 解析 CSV + reader = csv.DictReader(io.StringIO(content)) + if reader.fieldnames: + reader.fieldnames = [c.strip() for c in reader.fieldnames] + required_cols = ['employee_id', 'checkin_time', 'source', 'attendance_iot_id'] + missing = [c for c in required_cols if c not in (reader.fieldnames or [])] + if missing: + raise UserError( + _("CSV 缺少必要列: %s\n文件实际列名: %s") + % (', '.join(missing), ', '.join(reader.fieldnames or [])) + ) + + records = [] + for row in reader: + name = (row.get('employee_id') or '').strip() + checkin_str = (row.get('checkin_time') or '').strip() + device_sn = (row.get('attendance_iot_id') or '').strip() + remark = (row.get('remark') or '').strip() + if not name or not checkin_str: + continue + records.append({ + 'name': name, + 'checkin_str': checkin_str, + 'device_sn': device_sn, + 'remark': remark or (self.note or "日志补录"), + }) + + if not records: + raise UserError(_("CSV 文件中未找到有效记录!请检查 employee_id 和 checkin_time 列是否有数据。")) + + # 清空旧预览 + self.preview_line_ids.unlink() + + # 创建预览行 + preview_lines = [] + errors = [] + for rec in records: + employee = self.env['hr.employee'].search([('name', '=', rec['name'])], limit=1) + if not employee: + errors.append(_("未找到员工: %s") % rec['name']) + continue + preview_lines.append((0, 0, { + 'employee_name': rec['name'], + 'checkin_str': rec['checkin_str'], + 'device_sn': rec['device_sn'], + 'remark': rec['remark'], + })) + + self.write({ + 'preview_line_ids': preview_lines, + 'state': 'preview', + 'total_found': len(records), + 'total_skipped': len(errors), + 'error_message': '\n'.join(errors) if errors else '', + }) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'attendance.iot.import.wizard', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + 'name': _('预览打卡记录'), + } + + def action_import(self): + """执行导入""" + self.ensure_one() + if not self.preview_line_ids: + raise UserError(_("没有可导入的数据!")) + + employee_cache = {} + iot_cache = {} + + imported = 0 + skipped = 0 + errors = [] + + for line in self.preview_line_ids: + try: + # 查找员工 + if line.employee_name not in employee_cache: + emp = self.env['hr.employee'].search([('name', '=', line.employee_name)], limit=1) + employee_cache[line.employee_name] = emp + else: + emp = employee_cache[line.employee_name] + + if not emp: + errors.append(_("未找到员工: %s") % line.employee_name) + skipped += 1 + continue + + # 查找设备 + if line.device_sn and line.device_sn not in iot_cache: + iot = self.env['attendance.iot'].search([('name', '=', line.device_sn)], limit=1) + iot_cache[line.device_sn] = iot + else: + iot = iot_cache.get(line.device_sn) + + # 解析时间(CSV 中时间为北京时间 UTC+8) + checkin_naive = datetime.strptime(line.checkin_str, '%Y-%m-%d %H:%M:%S') + checkin_utc = checkin_naive - timedelta(hours=8) + + # 检查是否已存在(避免重复导入) + existing = self.env['hr.attendance.detail'].search_count([ + ('employee_id', '=', emp.id), + ('checkin_time', '=', checkin_utc), + ('source', '=', 'iot'), + ]) + if existing: + skipped += 1 + continue + + # 创建记录,跳过企业微信通知 + vals = { + 'employee_id': emp.id, + 'checkin_time': checkin_utc, + 'source': 'iot', + 'attendance_iot_id': iot.id if iot else False, + 'remark': line.remark, + } + self.env['hr.attendance.detail'].with_context(skip_iot_notify=True).create(vals) + imported += 1 + + except Exception as e: + errors.append("%s: %s" % (line.employee_name, str(e))) + skipped += 1 + _logger.error("Import error for %s: %s", line.employee_name, str(e)) + + self.write({ + 'state': 'done', + 'total_imported': imported, + 'total_skipped': skipped + self.total_skipped, + 'error_message': '\n'.join(errors) if errors else '', + }) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'attendance.iot.import.wizard', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + 'name': _('导入结果'), + } + + +class AttendanceIotImportPreviewLine(models.TransientModel): + _name = "attendance.iot.import.preview.line" + _description = "预览行" + + wizard_id = fields.Many2one('attendance.iot.import.wizard', string="向导", ondelete='cascade') + employee_name = fields.Char(string="员工姓名", readonly=True) + checkin_str = fields.Char(string="打卡时间", readonly=True) + device_sn = fields.Char(string="设备号", readonly=True) + remark = fields.Char(string="备注", readonly=True) diff --git a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml new file mode 100644 index 000000000..2bacc8f83 --- /dev/null +++ b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml @@ -0,0 +1,57 @@ + + + + attendance.iot.import.wizard.form + attendance.iot.import.wizard + +
+ + + + + + + + + +
+ 解析到 条记录, + 其中 条无法匹配 +
+ + + + + + + + + +
+ + + +
+

导入完成!

+

成功导入: 条

+

跳过: 条

+
+ +
+ + + + + +
+
+