diff --git a/yuthon_hr_attendance/security/ir.model.access.csv b/yuthon_hr_attendance/security/ir.model.access.csv index 557aa19b2..647d63cf9 100644 --- a/yuthon_hr_attendance/security/ir.model.access.csv +++ b/yuthon_hr_attendance/security/ir.model.access.csv @@ -105,4 +105,4 @@ access_vacation_meal_active_wizard,vacation.meal.active.wizard,model_vacation_me 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 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 +access_attendance_iot_import_preview_line,attendance.iot.import.preview.line,model_attendance_iot_import_preview_line,base.group_user,1,1,1,1 diff --git a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py index 2d74ccd1c..caa84afab 100644 --- a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py +++ b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.py @@ -3,6 +3,8 @@ import base64 import csv import io import logging +import re +import zipfile from datetime import datetime, timedelta from odoo import models, fields, api, _ @@ -10,16 +12,87 @@ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) +# 日志文件解析正则(二进制模式) +# 格式: UserRecordHelper record Save to database id: +_LOG_RECORD_RE = re.compile( + rb'UserRecordHelper record\s+(\S+)\s+(.+?)\s+' + rb'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d+)\s+Save to database id:\s*(\d+)' +) + + +def _decode_name(name_bytes): + """尝试用多种编码解码中文姓名""" + for enc in ('utf-8', 'gbk', 'gb18030', 'latin-1'): + try: + return name_bytes.decode(enc) + except Exception: + continue + return name_bytes.decode('latin-1', errors='replace') + + +def _extract_device_sn(zip_entry_name): + """从 zip 条目文件名提取设备 SN: _uploads_logs__""" + m = re.search(r'_uploads_logs_(\w+)_', zip_entry_name) + return m.group(1) if m else '' + + +def _parse_log_bytes(raw_bytes, device_sn=''): + """解析考勤机日志二进制内容,返回记录列表。每条记录: {name, checkin_str, device_sn}""" + records = [] + for line in raw_bytes.split(b'\n'): + m = _LOG_RECORD_RE.search(line) + if not m: + continue + name_bytes = m.group(2).strip() + name = _decode_name(name_bytes) + if not name or name.startswith('F'): + continue + checkin_str = m.group(3).decode('ascii') + records.append({ + 'name': name, + 'checkin_str': checkin_str, + 'device_sn': device_sn, + }) + return records + + +def _parse_csv_bytes(raw_bytes): + """解析 CSV 二进制内容,返回记录列表。每条记录: {name, checkin_str, device_sn, remark}""" + records = [] + try: + content = raw_bytes.decode('utf-8-sig') + except Exception: + return records, _("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'] + missing = [c for c in required_cols if c not in (reader.fieldnames or [])] + if missing: + return records, _("缺少必要列: %(missing)s,实际列: %(actual)s") % { + 'missing': ', '.join(missing), + 'actual': ', '.join(reader.fieldnames or []), + } + for row in reader: + name = (row.get('employee_id') or '').strip() + checkin_str = (row.get('checkin_time') or '').strip() + if not name or not checkin_str: + continue + records.append({ + 'name': name, + 'checkin_str': checkin_str, + 'device_sn': (row.get('attendance_iot_id') or '').strip(), + 'remark': (row.get('remark') or '').strip(), + }) + return records, None + 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="文件名") + zip_file = fields.Binary(string="ZIP 压缩包", required=True, attachment=False) + zip_filename = fields.Char(string="文件名") note = fields.Text(string="导入备注", default="日志补录") preview_line_ids = fields.One2many( 'attendance.iot.import.preview.line', @@ -39,54 +112,69 @@ class AttendanceIotImportWizard(models.TransientModel): error_message = fields.Text(string="错误信息", readonly=True) def action_parse(self): - """解析上传的 CSV 文件""" + """解压 zip 并解析其中所有支持的日志文件""" self.ensure_one() - if not self.log_file: - raise UserError(_("请先上传 CSV 文件!")) + if not self.zip_file: + raise UserError(_("请先上传 ZIP 压缩包!")) - # 解码文件内容(支持 utf-8 和 utf-8-sig/BOM) + raw_bytes = base64.b64decode(self.zip_file) + + # 验证是否是有效 zip try: - content = base64.b64decode(self.log_file).decode('utf-8-sig') - except Exception as e: - raise UserError(_("文件解码失败: %s") % str(e)) + zf = zipfile.ZipFile(io.BytesIO(raw_bytes)) + except zipfile.BadZipFile: + raise UserError(_("上传的文件不是有效的 ZIP 压缩包,请重新压缩后上传!")) - # 解析 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 [])) - ) + all_records = [] + errors = [] + file_errors = [] + entry_list = [e for e in zf.infolist() if not e.is_dir()] - 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 entry_list: + zf.close() + raise UserError(_("ZIP 压缩包中没有找到任何文件!")) - if not records: - raise UserError(_("CSV 文件中未找到有效记录!请检查 employee_id 和 checkin_time 列是否有数据。")) + for entry in entry_list: + entry_name = entry.filename + entry_lower = entry_name.lower() + entry_bytes = zf.read(entry) + + if entry_lower.endswith('.log'): + device_sn = _extract_device_sn(entry_name) + parsed = _parse_log_bytes(entry_bytes, device_sn) + for rec in parsed: + rec['remark'] = self.note or "日志补录" + all_records.append(rec) + + elif entry_lower.endswith('.csv'): + csv_records, csv_err = _parse_csv_bytes(entry_bytes) + if csv_err: + file_errors.append(_("文件 %s: %s") % (entry_name, csv_err)) + else: + for rec in csv_records: + if not rec.get('remark'): + rec['remark'] = self.note or "日志补录" + all_records.append(rec) + + else: + file_errors.append( + _("跳过不支持的文件: %s (仅支持 .log / .csv)") % entry_name + ) + + zf.close() + + if not all_records: + msg = _("所有文件中未找到有效记录!请检查压缩包内文件内容是否正确。") + if file_errors: + msg += "\n" + "\n".join(file_errors) + raise UserError(msg) # 清空旧预览 self.preview_line_ids.unlink() # 创建预览行 preview_lines = [] - errors = [] - for rec in records: + for rec in all_records: employee = self.env['hr.employee'].search([('name', '=', rec['name'])], limit=1) if not employee: errors.append(_("未找到员工: %s") % rec['name']) @@ -101,9 +189,9 @@ class AttendanceIotImportWizard(models.TransientModel): self.write({ 'preview_line_ids': preview_lines, 'state': 'preview', - 'total_found': len(records), + 'total_found': len(all_records), 'total_skipped': len(errors), - 'error_message': '\n'.join(errors) if errors else '', + 'error_message': '\n'.join(errors + file_errors) if (errors or file_errors) else '', }) return { @@ -130,7 +218,6 @@ class AttendanceIotImportWizard(models.TransientModel): 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 @@ -142,18 +229,15 @@ class AttendanceIotImportWizard(models.TransientModel): 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) + checkin_native = datetime.strptime(line.checkin_str, '%Y-%m-%d %H:%M:%S') + checkin_utc = checkin_native - timedelta(hours=8) - # 检查是否已存在(避免重复导入) existing = self.env['hr.attendance.detail'].search_count([ ('employee_id', '=', emp.id), ('checkin_time', '=', checkin_utc), @@ -163,7 +247,6 @@ class AttendanceIotImportWizard(models.TransientModel): skipped += 1 continue - # 创建记录,跳过企业微信通知 vals = { 'employee_id': emp.id, 'checkin_time': checkin_utc, diff --git a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml index 2bacc8f83..b449e5c90 100644 --- a/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml +++ b/yuthon_hr_attendance/wizard/attendance_iot_import_wizard.xml @@ -6,9 +6,11 @@
- - - + +

将多个 .log 或 .csv 日志文件打包为一个 .zip 压缩包后上传

+ +