考勤数据
This commit is contained in:
@@ -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
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user