模块迁移

This commit is contained in:
李鹏宇 2026-07-31 18:15:34 +08:00
parent 0d14744c15
commit cfe31fc044
24 changed files with 657 additions and 0 deletions

View File

@ -0,0 +1 @@
from . import models

View File

View File

View File

@ -0,0 +1 @@
from . import attendance

View File

@ -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)

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 列表视图 ============ -->
<record id="view_yuthon_attendance_list" model="ir.ui.view">
<field name="name">yuthon.attendance.list</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<list string="出勤记录" decoration-danger="status == 'absent'"
decoration-warning="status == 'late'">
<field name="employee_id"/>
<field name="date"/>
<field name="check_in"/>
<field name="check_out"/>
<field name="working_hours"/>
<field name="status"/>
<field name="late_minutes"/>
</list>
</field>
</record>
<!-- ============ 表单视图 ============ -->
<record id="view_yuthon_attendance_form" model="ir.ui.view">
<field name="name">yuthon.attendance.form</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<form string="出勤记录">
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="date"/>
<field name="check_in"/>
<field name="check_out"/>
</group>
<group>
<field name="working_hours"/>
<field name="status"/>
<field name="late_minutes"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ 搜索视图 ============ -->
<record id="view_yuthon_attendance_search" model="ir.ui.view">
<field name="name">yuthon.attendance搜索</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<search string="搜索出勤">
<field name="employee_id"/>
<field name="date"/>
<separator/>
<filter name="filter_late" string="迟到"
domain="[('status', '=', 'late')]"/>
<filter name="filter_absent" string="缺勤"
domain="[('status', '=', 'absent')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_employee" string="按教职工"
context="{'group_by': 'employee_id'}"/>
<filter name="groupby_status" string="按状态"
context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- ============ 图形(迟到分钟汇总) ============ -->
<record id="view_yuthon_attendance_graph" model="ir.ui.view">
<field name="name">yuthon.attendance.graph</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<graph string="各教职工迟到分钟数" type="bar">
<field name="employee_id" type="row"/>
<field name="late_minutes" type="measure"/>
</graph>
</field>
</record>
<!-- ============ 透视表 ============ -->
<record id="view_yuthon_attendance_pivot" model="ir.ui.view">
<field name="name">yuthon.attendance.pivot</field>
<field name="model">yuthon.attendance</field>
<field name="arch" type="xml">
<pivot string="出勤透视">
<field name="employee_id" type="row"/>
<field name="status" type="col"/>
<field name="late_minutes" type="measure"/>
</pivot>
</field>
</record>
<!-- ============ 动作 ============ -->
<record id="action_yuthon_attendance" model="ir.actions.act_window">
<field name="name">出勤记录</field>
<field name="res_model">yuthon.attendance</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="search_view_id" ref="view_yuthon_attendance_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
登记第一条出勤记录
</p>
</field>
</record>
<!-- ============ 子菜单(挂载到 yuthon_employee 的根菜单) ============ -->
<menuitem id="menu_yuthon_attendance" name="出勤"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_attendance" sequence="20"/>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,33 @@
{
'name': 'Yuthon 员工管理',
'version': '18.0.1.0.0',
'summary': '大学人事基础:教职工档案(工号/部门/岗位/基本工资)',
'description': """
教学模块①基础层yuthon_employee
======================================
定义教职工员工主数据模型 yuthon.employee是整个出勤-薪资体系的
数据根root后续 yuthon_attendanceyuthon_salary 都依赖本模块
教学要点
- 模型定义_name / _description
- 字段类型Char / Selection / Float / Date / Boolean
- 唯一约束_sql_constraints工号不可重复
- 模块组合被其它模块通过 _inherit 追加反向关联attendance_ids / payslip_ids
- ACLir.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,
}

View File

View File

@ -0,0 +1 @@
from . import employee

View File

@ -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('基本工资不能为负数。')

View File

View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@ -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,
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 工资条聚合“2026-07”整月的出勤记录来自 yuthon_attendance 的 demo -->
<record id="payslip_zhang_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_zhang"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">500.0</field>
</record>
<record id="payslip_li_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_li"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">300.0</field>
</record>
<record id="payslip_wang_2026_07" model="yuthon.payslip">
<field name="employee_id" ref="yuthon_employee.employee_wang"/>
<field name="period_start">2026-07-01</field>
<field name="period_end">2026-07-31</field>
<field name="allowance">800.0</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import payslip

View File

@ -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)

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 向导表单 ============ -->
<record id="view_yuthon_payslip_generate_form" model="ir.ui.view">
<field name="name">yuthon.payslip.generate.form</field>
<field name="model">yuthon.payslip.generate.wizard</field>
<field name="arch" type="xml">
<form string="生成月度工资条">
<group>
<field name="period_start"/>
<field name="period_end"/>
<field name="allowance"/>
<field name="employee_ids" widget="many2many_tags"/>
</group>
<footer>
<button name="action_generate" type="object"
string="生成" class="btn-primary"/>
<button string="取消" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ============ 向导动作 ============ -->
<record id="action_yuthon_payslip_generate" model="ir.actions.act_window">
<field name="name">生成月度工资条</field>
<field name="res_model">yuthon.payslip.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ============ 子菜单:放在“薪资”下,方便一键批量生成 ============ -->
<menuitem id="menu_yuthon_payslip_generate"
name="生成月度工资条"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_payslip_generate" sequence="31"/>
</odoo>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 列表视图 ============ -->
<record id="view_yuthon_payslip_list" model="ir.ui.view">
<field name="name">yuthon.payslip.list</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<list string="工资条" decoration-info="state == 'draft'">
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<field name="basic_wage"/>
<field name="allowance"/>
<field name="late_count"/>
<field name="absent_days"/>
<field name="attendance_deduction"/>
<field name="net_salary" decoration-bf="1"/>
<field name="state"/>
</list>
</field>
</record>
<!-- ============ 表单视图 ============ -->
<record id="view_yuthon_payslip_form" model="ir.ui.view">
<field name="name">yuthon.payslip.form</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<form string="工资条">
<header>
<button name="action_confirm" type="object"
string="确认" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_draft" type="object"
string="转草稿" invisible="state != 'done'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<field name="state" invisible="1"/>
</group>
<group>
<field name="basic_wage"/>
<field name="allowance"/>
</group>
</group>
<group>
<group string="出勤聚合">
<field name="late_count"/>
<field name="absent_days"/>
</group>
<group string="扣款与实发">
<field name="late_deduction"/>
<field name="absent_deduction"/>
<field name="attendance_deduction"/>
<field name="net_salary" decoration-bf="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ 搜索视图 ============ -->
<record id="view_yuthon_payslip_search" model="ir.ui.view">
<field name="name">yuthon.payslip搜索</field>
<field name="model">yuthon.payslip</field>
<field name="arch" type="xml">
<search string="搜索工资条">
<field name="employee_id"/>
<field name="period_start"/>
<field name="period_end"/>
<separator/>
<filter name="filter_draft" string="草稿"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_done" string="已确认"
domain="[('state', '=', 'done')]"/>
<separator/>
<group expand="0" string="分组">
<filter name="groupby_employee" string="按教职工"
context="{'group_by': 'employee_id'}"/>
</group>
</search>
</field>
</record>
<!-- ============ 动作 ============ -->
<record id="action_yuthon_payslip" model="ir.actions.act_window">
<field name="name">工资条</field>
<field name="res_model">yuthon.payslip</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_yuthon_payslip_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
先用「生成月度工资条」创建第一张工资条
</p>
</field>
</record>
<!-- ============ 子菜单(挂载到员工模块的根菜单) ============ -->
<menuitem id="menu_yuthon_payslip" name="薪资"
parent="yuthon_employee.menu_yuthon_hr_root"
action="action_yuthon_payslip" sequence="30"/>
</odoo>

View File