From cfe31fc044e24fde4f794a9d3a928ea8362f8afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= <9664676+pengyuthon@user.noreply.gitee.com> Date: Fri, 31 Jul 2026 18:15:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_attendance/__init__.py | 1 + yuthon_attendance/__manifest__.py | 0 yuthon_attendance/data/demo.xml | 0 yuthon_attendance/models/__init__.py | 1 + yuthon_attendance/models/attendance.py | 114 ++++++++++++++++++ .../security/ir.model.access.csv | 0 yuthon_attendance/views/attendance_views.xml | 114 ++++++++++++++++++ yuthon_employee/__init__.py | 1 + yuthon_employee/__manifest__.py | 33 +++++ yuthon_employee/data/demo.xml | 0 yuthon_employee/models/__init__.py | 1 + yuthon_employee/models/employee.py | 74 ++++++++++++ yuthon_employee/security/ir.model.access.csv | 0 yuthon_employee/views/employee_views.xml | 0 yuthon_salary/__init__.py | 2 + yuthon_salary/__manifest__.py | 35 ++++++ yuthon_salary/data/demo.xml | 26 ++++ yuthon_salary/models/__init__.py | 1 + yuthon_salary/models/payslip.py | 106 ++++++++++++++++ yuthon_salary/security/ir.model.access.csv | 0 .../views/payslip_generate_views.xml | 40 ++++++ yuthon_salary/views/payslip_views.xml | 108 +++++++++++++++++ yuthon_salary/wizards/__init__.py | 0 yuthon_salary/wizards/payslip_generate.py | 0 24 files changed, 657 insertions(+) create mode 100644 yuthon_attendance/__init__.py create mode 100644 yuthon_attendance/__manifest__.py create mode 100644 yuthon_attendance/data/demo.xml create mode 100644 yuthon_attendance/models/__init__.py create mode 100644 yuthon_attendance/models/attendance.py create mode 100644 yuthon_attendance/security/ir.model.access.csv create mode 100644 yuthon_attendance/views/attendance_views.xml create mode 100644 yuthon_employee/__init__.py create mode 100644 yuthon_employee/__manifest__.py create mode 100644 yuthon_employee/data/demo.xml create mode 100644 yuthon_employee/models/__init__.py create mode 100644 yuthon_employee/models/employee.py create mode 100644 yuthon_employee/security/ir.model.access.csv create mode 100644 yuthon_employee/views/employee_views.xml create mode 100644 yuthon_salary/__init__.py create mode 100644 yuthon_salary/__manifest__.py create mode 100644 yuthon_salary/data/demo.xml create mode 100644 yuthon_salary/models/__init__.py create mode 100644 yuthon_salary/models/payslip.py create mode 100644 yuthon_salary/security/ir.model.access.csv create mode 100644 yuthon_salary/views/payslip_generate_views.xml create mode 100644 yuthon_salary/views/payslip_views.xml create mode 100644 yuthon_salary/wizards/__init__.py create mode 100644 yuthon_salary/wizards/payslip_generate.py diff --git a/yuthon_attendance/__init__.py b/yuthon_attendance/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/yuthon_attendance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/yuthon_attendance/__manifest__.py b/yuthon_attendance/__manifest__.py new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_attendance/data/demo.xml b/yuthon_attendance/data/demo.xml new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_attendance/models/__init__.py b/yuthon_attendance/models/__init__.py new file mode 100644 index 0000000..72ac9f0 --- /dev/null +++ b/yuthon_attendance/models/__init__.py @@ -0,0 +1 @@ +from . import attendance diff --git a/yuthon_attendance/models/attendance.py b/yuthon_attendance/models/attendance.py new file mode 100644 index 0000000..b7c2d04 --- /dev/null +++ b/yuthon_attendance/models/attendance.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api +from datetime import timedelta + +# 教学用:规定上班时间为 09:00。 +# 注意:Odoo 数据库里的 Datetime 以 UTC 存储,这里直接用字段的“小时”做演示比较; +# 生产环境应先用 fields.Datetime.context_timestamp() 把时间转换到用户时区再判断。 +WORK_START_HOUR = 9 + + +class Attendance(models.Model): + """教职工出勤记录。""" + _name = 'yuthon.attendance' + _description = '出勤记录' + _order = 'date desc, employee_id' + + employee_id = fields.Many2one( + 'yuthon.employee', + string='教职工', + required=True, + ondelete='cascade', + ) + date = fields.Date( + string='日期', + required=True, + default=fields.Date.today, + ) + check_in = fields.Datetime(string='签到时间') + check_out = fields.Datetime(string='签退时间') + + # ---- 计算字段 ---- + working_hours = fields.Float( + string='工时(小时)', + compute='_compute_working_hours', + store=True, + ) + status = fields.Selection( + [ + ('normal', '正常'), + ('late', '迟到'), + ('absent', '缺勤'), + ], + string='状态', + compute='_compute_status', + store=True, + ) + late_minutes = fields.Integer( + string='迟到分钟', + compute='_compute_status', + store=True, + ) + + # ---- 约束 ---- + _sql_constraints = [ + ('uniq_employee_date', 'unique(employee_id, date)', + '同一教职工一天只能有一条出勤记录!'), + ] + + @api.constrains('check_in', 'check_out') + def _check_time_order(self): + for rec in self: + if rec.check_in and rec.check_out and rec.check_out < rec.check_in: + raise models.ValidationError('签退时间不能早于签到时间。') + + @api.depends('check_in', 'check_out') + def _compute_working_hours(self): + for rec in self: + if rec.check_in and rec.check_out and rec.check_out >= rec.check_in: + delta = rec.check_out - rec.check_in + rec.working_hours = round(delta.total_seconds() / 3600.0, 2) + else: + rec.working_hours = 0.0 + + @api.depends('check_in') + def _compute_status(self): + for rec in self: + if not rec.check_in: + rec.status = 'absent' + rec.late_minutes = 0 + continue + check_in_hour = rec.check_in.hour + rec.check_in.minute / 60.0 + if check_in_hour > WORK_START_HOUR: + rec.status = 'late' + threshold = rec.check_in.replace( + hour=WORK_START_HOUR, minute=0, second=0, microsecond=0) + rec.late_minutes = max(0, int( + (rec.check_in - threshold).total_seconds() // 60)) + else: + rec.status = 'normal' + rec.late_minutes = 0 + + +class Employee(models.Model): + """通过 _inherit 反向扩展 yuthon.employee,把出勤记录挂上去。 + + 这是「模块组合」的关键示范:yuthon_attendance 不修改 yuthon_employee 的源码, + 而是继承它追加 One2many 反向关联,让两个模块保持解耦。 + """ + _inherit = 'yuthon.employee' + + attendance_ids = fields.One2many( + 'yuthon.attendance', + 'employee_id', + string='出勤记录', + ) + attendance_count = fields.Integer( + string='出勤记录数', + compute='_compute_attendance_count', + ) + + @api.depends('attendance_ids') + def _compute_attendance_count(self): + for rec in self: + rec.attendance_count = len(rec.attendance_ids) diff --git a/yuthon_attendance/security/ir.model.access.csv b/yuthon_attendance/security/ir.model.access.csv new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_attendance/views/attendance_views.xml b/yuthon_attendance/views/attendance_views.xml new file mode 100644 index 0000000..bafe960 --- /dev/null +++ b/yuthon_attendance/views/attendance_views.xml @@ -0,0 +1,114 @@ + + + + + + yuthon.attendance.list + yuthon.attendance + + + + + + + + + + + + + + + + yuthon.attendance.form + yuthon.attendance + +
+ + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.attendance搜索 + yuthon.attendance + + + + + + + + + + + + + + + + + + + yuthon.attendance.graph + yuthon.attendance + + + + + + + + + + + yuthon.attendance.pivot + yuthon.attendance + + + + + + + + + + + + 出勤记录 + yuthon.attendance + list,form,graph,pivot + + +

+ 登记第一条出勤记录 +

+
+
+ + + + +
diff --git a/yuthon_employee/__init__.py b/yuthon_employee/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/yuthon_employee/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/yuthon_employee/__manifest__.py b/yuthon_employee/__manifest__.py new file mode 100644 index 0000000..9b2097d --- /dev/null +++ b/yuthon_employee/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'Yuthon 员工管理', + 'version': '18.0.1.0.0', + 'summary': '大学人事基础:教职工档案(工号/部门/岗位/基本工资)', + 'description': """ + 教学模块①(基础层):yuthon_employee + ====================================== + 定义教职工(员工)主数据模型 yuthon.employee,是整个「出勤-薪资」体系的 + 数据根(root)。后续 yuthon_attendance、yuthon_salary 都依赖本模块。 + + 教学要点: + - 模型定义:_name / _description + - 字段类型:Char / Selection / Float / Date / Boolean + - 唯一约束:_sql_constraints(工号不可重复) + - 模块组合:被其它模块通过 _inherit 追加反向关联(attendance_ids / payslip_ids) + - ACL:ir.model.access.csv 授权 + """, + 'author': 'Yuthon', + 'website': '', + 'category': 'Education', + 'depends': ['base', 'web'], + 'data': [ + 'security/ir.model.access.csv', + 'views/employee_views.xml', + ], + 'demo': [ + 'data/demo.xml', + ], + 'license': 'LGPL-3', + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/yuthon_employee/data/demo.xml b/yuthon_employee/data/demo.xml new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_employee/models/__init__.py b/yuthon_employee/models/__init__.py new file mode 100644 index 0000000..f7b5da3 --- /dev/null +++ b/yuthon_employee/models/__init__.py @@ -0,0 +1 @@ +from . import employee diff --git a/yuthon_employee/models/employee.py b/yuthon_employee/models/employee.py new file mode 100644 index 0000000..29c564b --- /dev/null +++ b/yuthon_employee/models/employee.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api + + +class Employee(models.Model): + """教职工(员工)主数据模型。 + + 这是「出勤-薪资」体系的**数据根**:出勤记录挂在员工下, + 工资条在月底把「员工基本工资 + 当月出勤聚合结果」join 在一起算出来。 + """ + _name = 'yuthon.employee' + _description = '教职工(员工)' + _order = 'employee_no' + + # ---- 基础信息 ---- + employee_no = fields.Char( + string='工号', + required=True, + index=True, + help='唯一工号,如 T2024001(教师)/ A2024002(行政)', + ) + name = fields.Char( + string='姓名', + required=True, + ) + gender = fields.Selection( + [('male', '男'), ('female', '女')], + string='性别', + ) + department = fields.Selection( + [ + ('teaching', '教学部'), + ('admin', '行政部'), + ('logistics', '后勤部'), + ('support', '教辅部'), + ], + string='部门', + required=True, + default='teaching', + ) + job_title = fields.Char( + string='岗位 / 职称', + help='如:讲师、副教授、教务员', + ) + phone = fields.Char(string='联系电话') + email = fields.Char(string='邮箱') + + # ---- 薪资相关(被 yuthon_salary 读取)---- + base_salary = fields.Float( + string='基本工资', + default=0.0, + help='教学演示用 Float;生产环境建议改用 Monetary + currency_id', + ) + hire_date = fields.Date(string='入职日期') + + active = fields.Boolean( + string='在职', + default=True, + ) + + # ---- 反向关联(由 yuthon_attendance / yuthon_salary 通过 _inherit 追加)---- + # attendance_ids / payslip_ids 不在此定义,避免基础模块反向依赖业务模块。 + # 这是「模块解耦」的核心:根模块不知道子模块的存在。 + + # ---- 约束 ---- + _sql_constraints = [ + ('uniq_employee_no', 'unique(employee_no)', '工号已存在,不可重复!'), + ] + + @api.constrains('base_salary') + def _check_base_salary(self): + for rec in self: + if rec.base_salary < 0: + raise models.ValidationError('基本工资不能为负数。') diff --git a/yuthon_employee/security/ir.model.access.csv b/yuthon_employee/security/ir.model.access.csv new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_employee/views/employee_views.xml b/yuthon_employee/views/employee_views.xml new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_salary/__init__.py b/yuthon_salary/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/yuthon_salary/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/yuthon_salary/__manifest__.py b/yuthon_salary/__manifest__.py new file mode 100644 index 0000000..b6bc57a --- /dev/null +++ b/yuthon_salary/__manifest__.py @@ -0,0 +1,35 @@ +{ + 'name': 'Yuthon 薪资管理', + 'version': '18.0.1.0.0', + 'summary': '工资条:聚合出勤迟到/缺勤,自动计算扣款与实发', + 'description': """ + 教学模块③(汇总层):yuthon_salary + ====================================== + 依赖 yuthon_employee + yuthon_attendance。定义工资条模型 yuthon.payslip, + 在月底把「员工基本工资 + 当月出勤聚合结果」join 在一起算出实发工资; + 并提供「生成月度工资条」向导做批量处理。 + + 教学要点: + - 跨模型聚合:env['yuthon.attendance'].search_count(...) 统计迟到/缺勤 + - computed field 多级联动:统计 -> 扣款 -> 实发 + - related 字段:basic_wage 取自员工基本工资 + - TransientModel 向导:批量生成工资条(讲解 wizard 与批量处理) + - 状态机:draft -> done + """, + 'author': 'Yuthon', + 'website': '', + 'category': 'Education', + 'depends': ['yuthon_employee', 'yuthon_attendance', 'web'], + 'data': [ + 'security/ir.model.access.csv', + 'views/payslip_views.xml', + 'views/payslip_generate_views.xml', + ], + 'demo': [ + 'data/demo.xml', + ], + 'license': 'LGPL-3', + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/yuthon_salary/data/demo.xml b/yuthon_salary/data/demo.xml new file mode 100644 index 0000000..841a6bb --- /dev/null +++ b/yuthon_salary/data/demo.xml @@ -0,0 +1,26 @@ + + + + + + + 2026-07-01 + 2026-07-31 + 500.0 + + + + + 2026-07-01 + 2026-07-31 + 300.0 + + + + + 2026-07-01 + 2026-07-31 + 800.0 + + + diff --git a/yuthon_salary/models/__init__.py b/yuthon_salary/models/__init__.py new file mode 100644 index 0000000..305e17f --- /dev/null +++ b/yuthon_salary/models/__init__.py @@ -0,0 +1 @@ +from . import payslip diff --git a/yuthon_salary/models/payslip.py b/yuthon_salary/models/payslip.py new file mode 100644 index 0000000..879fe8b --- /dev/null +++ b/yuthon_salary/models/payslip.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api + +# 教学用扣款规则(可在 production 改为可配置的 salary.rule 模型) +LATE_PENALTY = 50.0 # 迟到一次扣 50 +DAYS_PER_MONTH = 21.75 # 月计薪天数,用于把“缺勤天数”折算成金额 + + +class Payslip(models.Model): + """工资条:聚合员工当月出勤,自动计算扣款与实发。""" + _name = 'yuthon.payslip' + _description = '工资条' + _order = 'period_end desc, employee_id' + + employee_id = fields.Many2one( + 'yuthon.employee', + string='教职工', + required=True, + ondelete='cascade', + ) + period_start = fields.Date(string='起始日期', required=True) + period_end = fields.Date(string='结束日期', required=True) + + # ---- 金额 ---- + basic_wage = fields.Float( + string='基本工资', + related='employee_id.base_salary', + store=True, + readonly=True, + ) + allowance = fields.Float(string='津贴/补贴', default=0.0) + + # ---- 出勤聚合(自动统计)---- + late_count = fields.Integer( + string='迟到次数', compute='_compute_attendance_stats', store=True) + absent_days = fields.Integer( + string='缺勤天数', compute='_compute_attendance_stats', store=True) + + # ---- 扣款(自动计算)---- + late_deduction = fields.Float( + string='迟到扣款', compute='_compute_deductions', store=True) + absent_deduction = fields.Float( + string='缺勤扣款', compute='_compute_deductions', store=True) + attendance_deduction = fields.Float( + string='出勤扣款合计', compute='_compute_deductions', store=True) + + # ---- 实发 ---- + net_salary = fields.Float( + string='实发工资', compute='_compute_net', store=True) + + state = fields.Selection( + [('draft', '草稿'), ('done', '已确认')], + string='状态', + default='draft', + ) + + # ---- 计算逻辑 ---- + @api.depends('employee_id', 'period_start', 'period_end') + def _compute_attendance_stats(self): + Attendance = self.env['yuthon.attendance'] + for rec in self: + domain = [('employee_id', '=', rec.employee_id.id)] + if rec.period_start: + domain.append(('date', '>=', rec.period_start)) + if rec.period_end: + domain.append(('date', '<=', rec.period_end)) + # sudo() 确保在后台重算时不受当前用户 ACL 影响 + rec.late_count = Attendance.sudo().search_count( + domain + [('status', '=', 'late')]) + rec.absent_days = Attendance.sudo().search_count( + domain + [('status', '=', 'absent')]) + + @api.depends('late_count', 'absent_days', 'basic_wage') + def _compute_deductions(self): + for rec in self: + rec.late_deduction = rec.late_count * LATE_PENALTY + daily = (rec.basic_wage / DAYS_PER_MONTH) if rec.basic_wage else 0.0 + rec.absent_deduction = rec.absent_days * daily + rec.attendance_deduction = rec.late_deduction + rec.absent_deduction + + @api.depends('basic_wage', 'allowance', 'attendance_deduction') + def _compute_net(self): + for rec in self: + rec.net_salary = rec.basic_wage + rec.allowance - rec.attendance_deduction + + # ---- 业务动作 ---- + def action_confirm(self): + self.write({'state': 'done'}) + + def action_draft(self): + self.write({'state': 'draft'}) + + +class Employee(models.Model): + """_inherit 反向扩展:把工资条挂到员工上。""" + _inherit = 'yuthon.employee' + + payslip_ids = fields.One2many( + 'yuthon.payslip', 'employee_id', string='工资条') + payslip_count = fields.Integer( + string='工资条数', compute='_compute_payslip_count') + + @api.depends('payslip_ids') + def _compute_payslip_count(self): + for rec in self: + rec.payslip_count = len(rec.payslip_ids) diff --git a/yuthon_salary/security/ir.model.access.csv b/yuthon_salary/security/ir.model.access.csv new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_salary/views/payslip_generate_views.xml b/yuthon_salary/views/payslip_generate_views.xml new file mode 100644 index 0000000..28f6f78 --- /dev/null +++ b/yuthon_salary/views/payslip_generate_views.xml @@ -0,0 +1,40 @@ + + + + + + yuthon.payslip.generate.form + yuthon.payslip.generate.wizard + +
+ + + + + + +
+
+
+
+
+ + + + 生成月度工资条 + yuthon.payslip.generate.wizard + form + new + + + + + +
diff --git a/yuthon_salary/views/payslip_views.xml b/yuthon_salary/views/payslip_views.xml new file mode 100644 index 0000000..2331ab8 --- /dev/null +++ b/yuthon_salary/views/payslip_views.xml @@ -0,0 +1,108 @@ + + + + + + yuthon.payslip.list + yuthon.payslip + + + + + + + + + + + + + + + + + + + yuthon.payslip.form + yuthon.payslip + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + yuthon.payslip搜索 + yuthon.payslip + + + + + + + + + + + + + + + + + + + 工资条 + yuthon.payslip + list,form + + +

+ 先用「生成月度工资条」创建第一张工资条 +

+
+
+ + + + +
diff --git a/yuthon_salary/wizards/__init__.py b/yuthon_salary/wizards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yuthon_salary/wizards/payslip_generate.py b/yuthon_salary/wizards/payslip_generate.py new file mode 100644 index 0000000..e69de29