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