考勤数据

This commit is contained in:
李鹏宇
2026-06-04 11:54:03 +08:00
parent 685cbb890d
commit 40721723af
8 changed files with 288 additions and 5 deletions
+2
View File
@@ -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',
@@ -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
@@ -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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
103
104
105
106
107
108
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 导入向导 action(独立文件,确保先于视图加载) -->
<record id="action_attendance_iot_import_wizard" model="ir.actions.act_window">
<field name="name">导入考勤机日志</field>
<field name="res_model">attendance.iot.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
@@ -31,9 +31,10 @@
<field name="model">hr.attendance.detail</field>
<field name="arch" type="xml">
<tree string="hr attendance detail">
<!-- <header>-->
<!-- <button name="get_hr_attendance_time" type="object" string="手动计算" display="always"/>-->
<!-- </header>-->
<header>
<button name="%(yuthon_hr_attendance.action_attendance_iot_import_wizard)d"
type="action" string="导入考勤机日志" class="btn-primary" always="1"/>
</header>
<field name="employee_id"/>
<field name="checkin_time"/>
<field name="seconds"/>
+2 -1
View File
@@ -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
from . import customer_create_wizard
from . import attendance_iot_import_wizard
@@ -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)
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_attendance_iot_import_wizard_form" model="ir.ui.view">
<field name="name">attendance.iot.import.wizard.form</field>
<field name="model">attendance.iot.import.wizard</field>
<field name="arch" type="xml">
<form string="导入考勤机日志">
<!-- 状态1: 上传文件 -->
<group string="上传 CSV 文件" invisible="state != 'upload'">
<field name="log_file" filename="log_filename" required="1"/>
<field name="log_filename" invisible="1"/>
<field name="note" placeholder="可选备注,如:2026年5月日志补录"/>
</group>
<!-- 状态2: 预览 -->
<group string="预览解析结果" invisible="state != 'preview'">
<div style="margin-bottom: 8px;">
<span class="text-muted">解析到 <field name="total_found" class="oe_inline" readonly="1"/> 条记录,
其中 <field name="total_skipped" class="oe_inline" readonly="1"/> 条无法匹配</span>
</div>
<field name="error_message" invisible="not error_message" style="color: red;"/>
<field name="preview_line_ids" nolabel="1" readonly="1">
<tree create="0" delete="0" edit="0">
<field name="employee_name"/>
<field name="checkin_str"/>
<field name="device_sn"/>
<field name="remark"/>
</tree>
</field>
</group>
<!-- 状态3: 完成 -->
<group string="导入结果" invisible="state != 'done'">
<div>
<h3>导入完成!</h3>
<p>成功导入: <field name="total_imported" class="oe_inline" readonly="1"/> 条</p>
<p>跳过: <field name="total_skipped" class="oe_inline" readonly="1"/> 条</p>
</div>
<field name="error_message" invisible="not error_message" style="color: red;"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button name="action_parse" string="解析预览" type="object" class="oe_highlight"
invisible="state != 'upload'"/>
<button name="action_import" string="确认导入" type="object" class="oe_highlight"
invisible="state != 'preview'"/>
<button string="关闭" class="btn-secondary" special="cancel"
invisible="state == 'preview'"/>
<button string="返回" class="btn-secondary" special="cancel"
invisible="state != 'done'"/>
</footer>
</form>
</field>
</record>
</odoo>