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