feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initResourceCalendar registers resource.calendar — working schedules.
|
||||
// Mirrors: odoo/addons/resource/models/resource.py
|
||||
@@ -98,15 +103,181 @@ func initHREmployee() {
|
||||
orm.Binary("image_1920", orm.FieldOpts{String: "Image"}),
|
||||
)
|
||||
|
||||
// DefaultGet: provide dynamic defaults for new employees.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.default_get()
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
// Default company from current user's session
|
||||
companyID := env.CompanyID()
|
||||
if companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// toggle_active: archive/unarchive employee
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.toggle_active()
|
||||
m.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET active = NOT active WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: toggle_active for %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_archive: Archive employee (set active=false).
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee.action_archive()
|
||||
m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
_, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET active = false WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: action_archive for %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// _compute_remaining_leaves: Compute remaining leave days for the employee.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployee._compute_remaining_leaves()
|
||||
m.RegisterCompute("leaves_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var allocated float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||
FROM hr_leave_allocation
|
||||
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||
).Scan(&allocated); err != nil {
|
||||
return orm.Values{"leaves_count": float64(0)}, nil
|
||||
}
|
||||
|
||||
var used float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0)
|
||||
FROM hr_leave
|
||||
WHERE employee_id = $1 AND state = 'validate'`, empID,
|
||||
).Scan(&used); err != nil {
|
||||
return orm.Values{"leaves_count": float64(0)}, nil
|
||||
}
|
||||
|
||||
return orm.Values{"leaves_count": allocated - used}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("attendance_state", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var checkOut *string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT check_out FROM hr_attendance
|
||||
WHERE employee_id = $1
|
||||
ORDER BY check_in DESC LIMIT 1`, empID,
|
||||
).Scan(&checkOut)
|
||||
|
||||
if err != nil {
|
||||
// No attendance records or DB error → checked out
|
||||
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||
}
|
||||
if checkOut == nil {
|
||||
return orm.Values{"attendance_state": "checked_in"}, nil
|
||||
}
|
||||
return orm.Values{"attendance_state": "checked_out"}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrEmployeeCategory registers the hr.employee.category model.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee_category.py
|
||||
func initHrEmployeeCategory() {
|
||||
orm.NewModel("hr.employee.category", orm.ModelOpts{
|
||||
Description: "Employee Tag",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initHrEmployeePublic registers the hr.employee.public model with limited fields.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic
|
||||
func initHrEmployeePublic() {
|
||||
m := orm.NewModel("hr.employee.public", orm.ModelOpts{
|
||||
Description: "Public Employee",
|
||||
Order: "name",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department", Readonly: true}),
|
||||
orm.Char("job_title", orm.FieldOpts{String: "Job Title", Readonly: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Readonly: true}),
|
||||
orm.Many2one("parent_id", "hr.employee.public", orm.FieldOpts{String: "Manager", Readonly: true}),
|
||||
orm.Char("work_email", orm.FieldOpts{String: "Work Email", Readonly: true}),
|
||||
orm.Char("work_phone", orm.FieldOpts{String: "Work Phone", Readonly: true}),
|
||||
orm.Binary("image_1920", orm.FieldOpts{String: "Image", Readonly: true}),
|
||||
)
|
||||
|
||||
// get_public_data: Reads limited public fields from hr.employee.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py HrEmployeePublic.read()
|
||||
m.RegisterMethod("get_public_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Accept employee_id from kwargs or use IDs
|
||||
var employeeIDs []int64
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["employee_ids"]; ok {
|
||||
if ids, ok := v.([]int64); ok {
|
||||
employeeIDs = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(employeeIDs) == 0 {
|
||||
employeeIDs = rs.IDs()
|
||||
}
|
||||
if len(employeeIDs) == 0 {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, COALESCE(name, ''), COALESCE(department_id, 0),
|
||||
COALESCE(job_title, ''), COALESCE(company_id, 0),
|
||||
COALESCE(work_email, ''), COALESCE(work_phone, '')
|
||||
FROM hr_employee
|
||||
WHERE id = ANY($1) AND COALESCE(active, true) = true`, employeeIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee.public: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, deptID, companyID int64
|
||||
var name, jobTitle, email, phone string
|
||||
if err := rows.Scan(&id, &name, &deptID, &jobTitle, &companyID, &email, &phone); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"department_id": deptID,
|
||||
"job_title": jobTitle,
|
||||
"company_id": companyID,
|
||||
"work_email": email,
|
||||
"work_phone": phone,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrEmployeeExtensions adds skill, resume, attendance and leave fields
|
||||
@@ -117,12 +288,283 @@ func initHrEmployeeExtensions() {
|
||||
orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}),
|
||||
orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}),
|
||||
orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}),
|
||||
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Current Contract"}),
|
||||
orm.One2many("contract_ids", "hr.contract", "employee_id", orm.FieldOpts{String: "Contracts"}),
|
||||
orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}),
|
||||
orm.Selection("attendance_state", []orm.SelectionItem{
|
||||
{Value: "checked_out", Label: "Checked Out"},
|
||||
{Value: "checked_in", Label: "Checked In"},
|
||||
}, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}),
|
||||
orm.Float("seniority_years", orm.FieldOpts{
|
||||
String: "Seniority (Years)", Compute: "_compute_seniority_years",
|
||||
}),
|
||||
orm.Date("first_contract_date", orm.FieldOpts{String: "First Contract Date"}),
|
||||
orm.Many2many("category_ids", "hr.employee.category", orm.FieldOpts{String: "Tags"}),
|
||||
orm.Integer("age", orm.FieldOpts{
|
||||
String: "Age", Compute: "_compute_age",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_seniority_years: Years since first contract start date.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_employee.py _compute_first_contract_date
|
||||
emp.RegisterCompute("seniority_years", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Find earliest contract start date
|
||||
var firstDate *time.Time
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT MIN(date_start) FROM hr_contract
|
||||
WHERE employee_id = $1 AND state NOT IN ('cancel')`, empID,
|
||||
).Scan(&firstDate); err != nil {
|
||||
return orm.Values{"seniority_years": float64(0)}, nil
|
||||
}
|
||||
|
||||
if firstDate == nil {
|
||||
return orm.Values{"seniority_years": float64(0)}, nil
|
||||
}
|
||||
|
||||
years := time.Since(*firstDate).Hours() / (24 * 365.25)
|
||||
if years < 0 {
|
||||
years = 0
|
||||
}
|
||||
return orm.Values{"seniority_years": years}, nil
|
||||
})
|
||||
|
||||
// get_attendance_by_date_range: Return attendance summary for an employee.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py
|
||||
emp.RegisterMethod("get_attendance_by_date_range", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Parse date_from / date_to from kwargs
|
||||
dateFrom := time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||
dateTo := time.Now().Format("2006-01-02")
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||
dateFrom = v
|
||||
}
|
||||
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||
dateTo = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Daily attendance summary
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT check_in::date AS day,
|
||||
COUNT(*) AS entries,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0), 0) AS total_hours
|
||||
FROM hr_attendance
|
||||
WHERE employee_id = $1 AND check_in::date >= $2 AND check_in::date <= $3
|
||||
GROUP BY check_in::date
|
||||
ORDER BY check_in::date`, empID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: attendance report: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var days []map[string]interface{}
|
||||
var totalHours float64
|
||||
var totalDays int
|
||||
for rows.Next() {
|
||||
var day time.Time
|
||||
var entries int
|
||||
var hours float64
|
||||
if err := rows.Scan(&day, &entries, &hours); err != nil {
|
||||
continue
|
||||
}
|
||||
days = append(days, map[string]interface{}{
|
||||
"date": day.Format("2006-01-02"),
|
||||
"entries": entries,
|
||||
"hours": hours,
|
||||
})
|
||||
totalHours += hours
|
||||
totalDays++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"employee_id": empID,
|
||||
"date_from": dateFrom,
|
||||
"date_to": dateTo,
|
||||
"days": days,
|
||||
"total_days": totalDays,
|
||||
"total_hours": totalHours,
|
||||
"avg_hours": func() float64 { if totalDays > 0 { return totalHours / float64(totalDays) }; return 0 }(),
|
||||
}, nil
|
||||
})
|
||||
|
||||
// _compute_age: Compute employee age from birthday.
|
||||
// Mirrors: odoo/addons/hr/models/hr_employee.py _compute_age()
|
||||
emp.RegisterCompute("age", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
var birthday *time.Time
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT birthday FROM hr_employee WHERE id = $1`, empID,
|
||||
).Scan(&birthday); err != nil || birthday == nil {
|
||||
return orm.Values{"age": int64(0)}, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
age := now.Year() - birthday.Year()
|
||||
// Adjust if birthday has not occurred yet this year
|
||||
if now.Month() < birthday.Month() ||
|
||||
(now.Month() == birthday.Month() && now.Day() < birthday.Day()) {
|
||||
age--
|
||||
}
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return orm.Values{"age": int64(age)}, nil
|
||||
})
|
||||
|
||||
// action_check_in: Create a new attendance record with check_in = now.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_in()
|
||||
emp.RegisterMethod("action_check_in", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
for _, empID := range rs.IDs() {
|
||||
// Verify employee is not already checked in
|
||||
var openCount int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_attendance
|
||||
WHERE employee_id = $1 AND check_out IS NULL`, empID,
|
||||
).Scan(&openCount)
|
||||
if openCount > 0 {
|
||||
return nil, fmt.Errorf("hr.employee: employee %d is already checked in", empID)
|
||||
}
|
||||
|
||||
// Get company_id from employee
|
||||
var companyID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT company_id FROM hr_employee WHERE id = $1`, empID,
|
||||
).Scan(&companyID)
|
||||
|
||||
// Create attendance record
|
||||
attRS := env.Model("hr.attendance")
|
||||
vals := orm.Values{
|
||||
"employee_id": empID,
|
||||
"check_in": now,
|
||||
}
|
||||
if companyID != nil {
|
||||
vals["company_id"] = *companyID
|
||||
}
|
||||
if _, err := attRS.Create(vals); err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: check_in for %d: %w", empID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_check_out: Set check_out on the latest open attendance record.
|
||||
// Mirrors: odoo/addons/hr_attendance/models/hr_employee.py action_check_out()
|
||||
emp.RegisterMethod("action_check_out", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
for _, empID := range rs.IDs() {
|
||||
result, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_attendance SET check_out = $1
|
||||
WHERE employee_id = $2 AND check_out IS NULL`, now, empID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: check_out for %d: %w", empID, err)
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return nil, fmt.Errorf("hr.employee: employee %d is not checked in", empID)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// get_org_chart: Return hierarchical org chart data for the employee.
|
||||
// Mirrors: odoo/addons/hr_org_chart/models/hr_employee.py get_org_chart()
|
||||
emp.RegisterMethod("get_org_chart", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
empID := rs.IDs()[0]
|
||||
|
||||
// Recursive function to build the tree
|
||||
var buildNode func(id int64, depth int) (map[string]interface{}, error)
|
||||
buildNode = func(id int64, depth int) (map[string]interface{}, error) {
|
||||
// Prevent infinite recursion
|
||||
if depth > 20 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var name, jobTitle string
|
||||
var deptID, parentID *int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name, ''), COALESCE(job_title, ''),
|
||||
department_id, parent_id
|
||||
FROM hr_employee
|
||||
WHERE id = $1 AND COALESCE(active, true) = true`, id,
|
||||
).Scan(&name, &jobTitle, &deptID, &parentID)
|
||||
if err != nil {
|
||||
return nil, nil // employee not found or inactive
|
||||
}
|
||||
|
||||
node := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"job_title": jobTitle,
|
||||
}
|
||||
if deptID != nil {
|
||||
node["department_id"] = *deptID
|
||||
}
|
||||
if parentID != nil {
|
||||
node["parent_id"] = *parentID
|
||||
}
|
||||
|
||||
// Find subordinates
|
||||
subRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id FROM hr_employee
|
||||
WHERE parent_id = $1 AND COALESCE(active, true) = true
|
||||
ORDER BY name`, id)
|
||||
if err != nil {
|
||||
node["subordinates"] = []map[string]interface{}{}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
var subIDs []int64
|
||||
for subRows.Next() {
|
||||
var subID int64
|
||||
if err := subRows.Scan(&subID); err != nil {
|
||||
continue
|
||||
}
|
||||
subIDs = append(subIDs, subID)
|
||||
}
|
||||
subRows.Close()
|
||||
|
||||
var subordinates []map[string]interface{}
|
||||
for _, subID := range subIDs {
|
||||
subNode, err := buildNode(subID, depth+1)
|
||||
if err != nil || subNode == nil {
|
||||
continue
|
||||
}
|
||||
subordinates = append(subordinates, subNode)
|
||||
}
|
||||
|
||||
if subordinates == nil {
|
||||
subordinates = []map[string]interface{}{}
|
||||
}
|
||||
node["subordinates"] = subordinates
|
||||
node["subordinate_count"] = len(subordinates)
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
chart, err := buildNode(empID, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.employee: get_org_chart: %w", err)
|
||||
}
|
||||
if chart == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return chart, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHRDepartment registers the hr.department model.
|
||||
@@ -149,6 +591,14 @@ func initHRDepartment() {
|
||||
orm.One2many("child_ids", "hr.department", "parent_id", orm.FieldOpts{String: "Child Departments"}),
|
||||
orm.One2many("member_ids", "hr.employee", "department_id", orm.FieldOpts{String: "Members"}),
|
||||
)
|
||||
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
if companyID := env.CompanyID(); companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
// initHRJob registers the hr.job model.
|
||||
@@ -174,4 +624,12 @@ func initHRJob() {
|
||||
}, orm.FieldOpts{String: "Status", Required: true, Default: "recruit"}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Job Description"}),
|
||||
)
|
||||
|
||||
m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
|
||||
vals := make(orm.Values)
|
||||
if companyID := env.CompanyID(); companyID > 0 {
|
||||
vals["company_id"] = companyID
|
||||
}
|
||||
return vals
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ func initHrAttendance() {
|
||||
env := rs.Env()
|
||||
attID := rs.IDs()[0]
|
||||
var hours float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (check_out - check_in)) / 3600.0, 0)
|
||||
FROM hr_attendance WHERE id = $1 AND check_out IS NOT NULL`, attID,
|
||||
).Scan(&hours)
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (COALESCE(check_out, NOW()) - check_in)) / 3600.0, 0)
|
||||
FROM hr_attendance WHERE id = $1`, attID,
|
||||
).Scan(&hours); err != nil {
|
||||
return orm.Values{"worked_hours": float64(0)}, nil
|
||||
}
|
||||
return orm.Values{"worked_hours": hours}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// initHrContract registers the hr.contract model.
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrContract registers the hr.contract model with full lifecycle.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py
|
||||
func initHrContract() {
|
||||
m := orm.NewModel("hr.contract", orm.ModelOpts{
|
||||
@@ -10,22 +15,383 @@ func initHrContract() {
|
||||
Order: "date_start desc",
|
||||
})
|
||||
|
||||
// -- Core Fields --
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Contract Reference", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}),
|
||||
orm.Many2one("job_id", "hr.job", orm.FieldOpts{String: "Job Position"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
|
||||
// -- Contract Type & Duration --
|
||||
m.AddFields(
|
||||
orm.Selection("contract_type", []orm.SelectionItem{
|
||||
{Value: "permanent", Label: "Permanent"},
|
||||
{Value: "fixed_term", Label: "Fixed Term"},
|
||||
{Value: "probation", Label: "Probation"},
|
||||
{Value: "freelance", Label: "Freelance / Contractor"},
|
||||
{Value: "internship", Label: "Internship"},
|
||||
}, orm.FieldOpts{String: "Contract Type", Default: "permanent"}),
|
||||
orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}),
|
||||
orm.Date("date_end", orm.FieldOpts{String: "End Date"}),
|
||||
orm.Monetary("wage", orm.FieldOpts{String: "Wage", Required: true, CurrencyField: "currency_id"}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Integer("trial_period_days", orm.FieldOpts{String: "Trial Period (Days)"}),
|
||||
orm.Date("trial_date_end", orm.FieldOpts{String: "Trial End Date"}),
|
||||
orm.Integer("notice_period_days", orm.FieldOpts{
|
||||
String: "Notice Period (Days)", Default: 30,
|
||||
}),
|
||||
)
|
||||
|
||||
// -- State Machine --
|
||||
m.AddFields(
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "New"},
|
||||
{Value: "open", Label: "Running"},
|
||||
{Value: "pending", Label: "To Renew"},
|
||||
{Value: "close", Label: "Expired"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||
)
|
||||
|
||||
// -- Compensation --
|
||||
m.AddFields(
|
||||
orm.Monetary("wage", orm.FieldOpts{
|
||||
String: "Wage (Gross)", Required: true, CurrencyField: "currency_id",
|
||||
Help: "Gross monthly salary",
|
||||
}),
|
||||
orm.Selection("schedule_pay", []orm.SelectionItem{
|
||||
{Value: "monthly", Label: "Monthly"},
|
||||
{Value: "weekly", Label: "Weekly"},
|
||||
{Value: "bi_weekly", Label: "Bi-Weekly"},
|
||||
{Value: "yearly", Label: "Yearly"},
|
||||
}, orm.FieldOpts{String: "Scheduled Pay", Default: "monthly"}),
|
||||
orm.Monetary("wage_annual", orm.FieldOpts{
|
||||
String: "Annual Wage", Compute: "_compute_wage_annual", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("bonus", orm.FieldOpts{
|
||||
String: "Bonus", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("transport_allowance", orm.FieldOpts{
|
||||
String: "Transport Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("meal_allowance", orm.FieldOpts{
|
||||
String: "Meal Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("other_allowance", orm.FieldOpts{
|
||||
String: "Other Allowance", CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Monetary("total_compensation", orm.FieldOpts{
|
||||
String: "Total Compensation", Compute: "_compute_total_compensation", CurrencyField: "currency_id",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Working Schedule --
|
||||
m.AddFields(
|
||||
orm.Many2one("resource_calendar_id", "resource.calendar", orm.FieldOpts{
|
||||
String: "Working Schedule",
|
||||
}),
|
||||
orm.Float("hours_per_week", orm.FieldOpts{String: "Hours per Week", Default: 40.0}),
|
||||
)
|
||||
|
||||
// -- History & Links --
|
||||
m.AddFields(
|
||||
orm.Many2one("previous_contract_id", "hr.contract", orm.FieldOpts{
|
||||
String: "Previous Contract",
|
||||
}),
|
||||
orm.Text("notes", orm.FieldOpts{String: "Notes"}),
|
||||
)
|
||||
|
||||
// -- Computed: days_remaining --
|
||||
m.AddFields(
|
||||
orm.Integer("days_remaining", orm.FieldOpts{
|
||||
String: "Days Remaining", Compute: "_compute_days_remaining",
|
||||
}),
|
||||
orm.Boolean("is_expired", orm.FieldOpts{
|
||||
String: "Is Expired", Compute: "_compute_is_expired",
|
||||
}),
|
||||
orm.Boolean("is_expiring_soon", orm.FieldOpts{
|
||||
String: "Expiring Soon", Compute: "_compute_is_expiring_soon",
|
||||
Help: "Contract expires within 30 days",
|
||||
}),
|
||||
)
|
||||
|
||||
// -- Computes --
|
||||
|
||||
m.RegisterCompute("days_remaining", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end FROM hr_contract WHERE id = $1`, id).Scan(&dateEnd)
|
||||
if dateEnd == nil {
|
||||
return orm.Values{"days_remaining": int64(0)}, nil
|
||||
}
|
||||
days := int64(time.Until(*dateEnd).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return orm.Values{"days_remaining": days}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("is_expired", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&dateEnd, &state)
|
||||
expired := dateEnd != nil && dateEnd.Before(time.Now()) && state != "close" && state != "cancel"
|
||||
return orm.Values{"is_expired": expired}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("is_expiring_soon", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var dateEnd *time.Time
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT date_end, COALESCE(state, 'draft') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&dateEnd, &state)
|
||||
soon := false
|
||||
if dateEnd != nil && state == "open" {
|
||||
daysLeft := time.Until(*dateEnd).Hours() / 24
|
||||
soon = daysLeft > 0 && daysLeft <= 30
|
||||
}
|
||||
return orm.Values{"is_expiring_soon": soon}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("wage_annual", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var wage float64
|
||||
var schedulePay string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0), COALESCE(schedule_pay, 'monthly') FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&wage, &schedulePay)
|
||||
var annual float64
|
||||
switch schedulePay {
|
||||
case "monthly":
|
||||
annual = wage * 12
|
||||
case "weekly":
|
||||
annual = wage * 52
|
||||
case "bi_weekly":
|
||||
annual = wage * 26
|
||||
case "yearly":
|
||||
annual = wage
|
||||
}
|
||||
return orm.Values{"wage_annual": annual}, nil
|
||||
})
|
||||
|
||||
m.RegisterCompute("total_compensation", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var wage, bonus, transport, meal, other float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||
COALESCE(meal_allowance,0), COALESCE(other_allowance,0)
|
||||
FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&wage, &bonus, &transport, &meal, &other)
|
||||
return orm.Values{"total_compensation": wage + bonus + transport + meal + other}, nil
|
||||
})
|
||||
|
||||
// -- State Machine Methods --
|
||||
|
||||
// action_open: draft/pending → open (activate contract)
|
||||
m.RegisterMethod("action_open", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'open'
|
||||
WHERE id = $1 AND state IN ('draft', 'pending')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: open %d: %w", id, err)
|
||||
}
|
||||
// Set as current contract on employee
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_employee SET contract_id = $1
|
||||
WHERE id = (SELECT employee_id FROM hr_contract WHERE id = $1)`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: update employee contract link %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_pending: open → pending (mark for renewal)
|
||||
m.RegisterMethod("action_pending", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'pending' WHERE id = $1 AND state = 'open'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: pending %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_close: → close (expire contract)
|
||||
m.RegisterMethod("action_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close' WHERE id = $1 AND state NOT IN ('cancel')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: close %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: → cancel
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'cancel'
|
||||
WHERE id = $1 AND state IN ('draft', 'open', 'pending')`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: → draft (reset)
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: draft %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_renew: Create a new contract from this one (close current, create copy)
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py action_renew()
|
||||
m.RegisterMethod("action_renew", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
|
||||
// Read current contract
|
||||
var employeeID, departmentID, jobID, companyID, currencyID, calendarID int64
|
||||
var wage, bonus, transport, meal, other, hoursPerWeek float64
|
||||
var contractType, schedulePay, name string
|
||||
var noticePeriod int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT employee_id, COALESCE(department_id,0), COALESCE(job_id,0),
|
||||
COALESCE(company_id,0), COALESCE(currency_id,0),
|
||||
COALESCE(resource_calendar_id,0),
|
||||
COALESCE(wage,0), COALESCE(bonus,0), COALESCE(transport_allowance,0),
|
||||
COALESCE(meal_allowance,0), COALESCE(other_allowance,0),
|
||||
COALESCE(hours_per_week,40),
|
||||
COALESCE(contract_type,'permanent'), COALESCE(schedule_pay,'monthly'),
|
||||
COALESCE(name,''), COALESCE(notice_period_days,30)
|
||||
FROM hr_contract WHERE id = $1`, id,
|
||||
).Scan(&employeeID, &departmentID, &jobID, &companyID, ¤cyID, &calendarID,
|
||||
&wage, &bonus, &transport, &meal, &other, &hoursPerWeek,
|
||||
&contractType, &schedulePay, &name, ¬icePeriod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: read for renew %d: %w", id, err)
|
||||
}
|
||||
|
||||
// Close current contract
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: close for renewal %d: %w", id, err)
|
||||
}
|
||||
|
||||
// Create new contract
|
||||
newVals := orm.Values{
|
||||
"name": name + " (Renewal)",
|
||||
"employee_id": employeeID,
|
||||
"date_start": time.Now().Format("2006-01-02"),
|
||||
"wage": wage,
|
||||
"contract_type": contractType,
|
||||
"schedule_pay": schedulePay,
|
||||
"notice_period_days": noticePeriod,
|
||||
"bonus": bonus,
|
||||
"transport_allowance": transport,
|
||||
"meal_allowance": meal,
|
||||
"other_allowance": other,
|
||||
"hours_per_week": hoursPerWeek,
|
||||
"previous_contract_id": id,
|
||||
"state": "draft",
|
||||
}
|
||||
if departmentID > 0 {
|
||||
newVals["department_id"] = departmentID
|
||||
}
|
||||
if jobID > 0 {
|
||||
newVals["job_id"] = jobID
|
||||
}
|
||||
if companyID > 0 {
|
||||
newVals["company_id"] = companyID
|
||||
}
|
||||
if currencyID > 0 {
|
||||
newVals["currency_id"] = currencyID
|
||||
}
|
||||
if calendarID > 0 {
|
||||
newVals["resource_calendar_id"] = calendarID
|
||||
}
|
||||
|
||||
contractRS := env.Model("hr.contract")
|
||||
newContract, err := contractRS.Create(newVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: create renewal: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "hr.contract",
|
||||
"res_id": newContract.ID(),
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"target": "current",
|
||||
}, nil
|
||||
})
|
||||
|
||||
// -- BeforeWrite: State Guard --
|
||||
m.BeforeWrite = orm.StateGuard("hr_contract", "state IN ('close', 'cancel')",
|
||||
[]string{"write_uid", "write_date", "state", "active"},
|
||||
"cannot modify closed/cancelled contracts")
|
||||
}
|
||||
|
||||
// initHrContractCron registers the contract expiration check cron job.
|
||||
// Should be called after initHrContract and cron system is ready.
|
||||
func initHrContractCron() {
|
||||
m := orm.ExtendModel("hr.contract")
|
||||
|
||||
// _cron_check_expiring: Auto-close expired contracts, set pending for expiring soon.
|
||||
// Mirrors: odoo/addons/hr_contract/models/hr_contract.py _cron_check_expiring()
|
||||
m.RegisterMethod("_cron_check_expiring", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// Close expired contracts
|
||||
result, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'close'
|
||||
WHERE state = 'open' AND date_end IS NOT NULL AND date_end < $1`, today)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cron close expired: %w", err)
|
||||
}
|
||||
closed := result.RowsAffected()
|
||||
|
||||
// Mark contracts expiring within 30 days as pending
|
||||
thirtyDays := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
|
||||
result2, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_contract SET state = 'pending'
|
||||
WHERE state = 'open' AND date_end IS NOT NULL
|
||||
AND date_end >= $1 AND date_end <= $2`, today, thirtyDays)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.contract: cron mark pending: %w", err)
|
||||
}
|
||||
pending := result2.RowsAffected()
|
||||
|
||||
if closed > 0 || pending > 0 {
|
||||
fmt.Printf("hr.contract cron: closed %d, marked pending %d\n", closed, pending)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrExpense registers the hr.expense and hr.expense.sheet models.
|
||||
// Mirrors: odoo/addons/hr_expense/models/hr_expense.py
|
||||
@@ -35,10 +40,63 @@ func initHrExpense() {
|
||||
orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}),
|
||||
)
|
||||
|
||||
orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
||||
// -- Expense Methods --
|
||||
|
||||
// action_submit: draft → reported
|
||||
exp := orm.Registry.Get("hr.expense")
|
||||
if exp != nil {
|
||||
exp.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'reported' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: submit %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// _action_validate_expense: Check that expense has amount > 0 and a receipt.
|
||||
exp.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var amount float64
|
||||
var state string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(total_amount, 0), COALESCE(state, 'draft') FROM hr_expense WHERE id = $1`, id,
|
||||
).Scan(&amount, &state)
|
||||
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("hr.expense: expense %d has no amount", id)
|
||||
}
|
||||
if state != "reported" {
|
||||
return nil, fmt.Errorf("hr.expense: expense %d must be submitted first (state: %s)", id, state)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'approved' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: validate %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
exp.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'refused' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense: refuse %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
sheet := orm.NewModel("hr.expense.sheet", orm.ModelOpts{
|
||||
Description: "Expense Report",
|
||||
Order: "create_date desc",
|
||||
}).AddFields(
|
||||
})
|
||||
sheet.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}),
|
||||
orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}),
|
||||
@@ -55,5 +113,240 @@ func initHrExpense() {
|
||||
{Value: "cancel", Label: "Refused"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}),
|
||||
orm.Integer("expense_count", orm.FieldOpts{String: "Expense Count", Compute: "_compute_expense_count"}),
|
||||
)
|
||||
|
||||
// _compute_total: Sum of expense amounts.
|
||||
sheet.RegisterCompute("total_amount", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var total float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(total_amount::float8), 0) FROM hr_expense WHERE sheet_id = $1`, id,
|
||||
).Scan(&total)
|
||||
return orm.Values{"total_amount": total}, nil
|
||||
})
|
||||
|
||||
sheet.RegisterCompute("expense_count", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var count int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||
return orm.Values{"expense_count": count}, nil
|
||||
})
|
||||
|
||||
// -- Expense Sheet Workflow Methods --
|
||||
|
||||
sheet.RegisterMethod("action_submit", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
// Validate: must have at least one expense line
|
||||
var count int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM hr_expense WHERE sheet_id = $1`, id).Scan(&count)
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: cannot submit empty report %d", id)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'submit' WHERE id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: submit %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'reported' WHERE sheet_id = $1 AND state = 'draft'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for submit %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'approve' WHERE id = $1 AND state = 'submit'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: approve %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'approved' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for approve %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: refuse %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'refused' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for refuse %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_post: Create a journal entry (account.move) from approved expense sheet.
|
||||
// Debit: expense account, Credit: payable account.
|
||||
// Mirrors: odoo/addons/hr_expense/models/hr_expense_sheet.py action_sheet_move_create()
|
||||
sheet.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, sheetID := range rs.IDs() {
|
||||
// Validate state = approve
|
||||
var state string
|
||||
var employeeID int64
|
||||
var companyID *int64
|
||||
var currencyID *int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(state, 'draft'), employee_id,
|
||||
company_id, currency_id
|
||||
FROM hr_expense_sheet WHERE id = $1`, sheetID,
|
||||
).Scan(&state, &employeeID, &companyID, ¤cyID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: read %d: %w", sheetID, err)
|
||||
}
|
||||
if state != "approve" {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: can only post approved reports (sheet %d is %q)", sheetID, state)
|
||||
}
|
||||
|
||||
// Fetch expense lines
|
||||
expRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, name, COALESCE(total_amount, 0), account_id
|
||||
FROM hr_expense WHERE sheet_id = $1`, sheetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: fetch expenses for %d: %w", sheetID, err)
|
||||
}
|
||||
|
||||
type expLine struct {
|
||||
id int64
|
||||
name string
|
||||
amount float64
|
||||
accountID *int64
|
||||
}
|
||||
var lines []expLine
|
||||
var total float64
|
||||
for expRows.Next() {
|
||||
var l expLine
|
||||
if err := expRows.Scan(&l.id, &l.name, &l.amount, &l.accountID); err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, l)
|
||||
total += l.amount
|
||||
}
|
||||
expRows.Close()
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: no expenses to post on sheet %d", sheetID)
|
||||
}
|
||||
|
||||
// Get employee's home address partner for payable line
|
||||
var partnerID *int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT address_home_id FROM hr_employee WHERE id = $1`, employeeID,
|
||||
).Scan(&partnerID)
|
||||
|
||||
// Create account.move
|
||||
moveVals := orm.Values{
|
||||
"move_type": "in_invoice",
|
||||
"state": "draft",
|
||||
"date": time.Now().Format("2006-01-02"),
|
||||
}
|
||||
if companyID != nil {
|
||||
moveVals["company_id"] = *companyID
|
||||
}
|
||||
if currencyID != nil {
|
||||
moveVals["currency_id"] = *currencyID
|
||||
}
|
||||
if partnerID != nil {
|
||||
moveVals["partner_id"] = *partnerID
|
||||
}
|
||||
|
||||
moveRS := env.Model("account.move")
|
||||
move, err := moveRS.Create(moveVals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create journal entry for %d: %w", sheetID, err)
|
||||
}
|
||||
moveID := move.ID()
|
||||
|
||||
// Create move lines: one debit line per expense, one credit (payable) line for total
|
||||
for _, l := range lines {
|
||||
debitVals := orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": l.name,
|
||||
"debit": l.amount,
|
||||
"credit": float64(0),
|
||||
}
|
||||
if l.accountID != nil {
|
||||
debitVals["account_id"] = *l.accountID
|
||||
}
|
||||
if partnerID != nil {
|
||||
debitVals["partner_id"] = *partnerID
|
||||
}
|
||||
lineRS := env.Model("account.move.line")
|
||||
if _, err := lineRS.Create(debitVals); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create debit line: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Credit line (payable) — find payable account
|
||||
var payableAccID int64
|
||||
cid := int64(0)
|
||||
if companyID != nil {
|
||||
cid = *companyID
|
||||
}
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM account_account
|
||||
WHERE account_type = 'liability_payable' AND company_id = $1
|
||||
ORDER BY code LIMIT 1`, cid).Scan(&payableAccID)
|
||||
|
||||
creditVals := orm.Values{
|
||||
"move_id": moveID,
|
||||
"name": "Employee Expense Payable",
|
||||
"debit": float64(0),
|
||||
"credit": total,
|
||||
}
|
||||
if payableAccID > 0 {
|
||||
creditVals["account_id"] = payableAccID
|
||||
}
|
||||
if partnerID != nil {
|
||||
creditVals["partner_id"] = *partnerID
|
||||
}
|
||||
lineRS := env.Model("account.move.line")
|
||||
if _, err := lineRS.Create(creditVals); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: create credit line: %w", err)
|
||||
}
|
||||
|
||||
// Update expense sheet state and link to move
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'post', account_move_id = $1 WHERE id = $2`,
|
||||
moveID, sheetID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update state to post %d: %w", sheetID, err)
|
||||
}
|
||||
|
||||
// Update expense line states
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'done' WHERE sheet_id = $1`, sheetID); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update expense states %d: %w", sheetID, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
sheet.RegisterMethod("action_reset", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense_sheet SET state = 'draft' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: reset %d: %w", id, err)
|
||||
}
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_expense SET state = 'draft' WHERE sheet_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.expense.sheet: update lines for reset %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrLeaveType registers the hr.leave.type model.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py
|
||||
@@ -52,39 +57,378 @@ func initHrLeave() {
|
||||
orm.Text("notes", orm.FieldOpts{String: "Reasons"}),
|
||||
)
|
||||
|
||||
// action_approve: Manager approves leave request (first approval).
|
||||
// For leave types with 'both' validation, moves to validate1 (second approval needed).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_approve()
|
||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'validate' WHERE id = $1 AND state IN ('confirm','validate1')`, id)
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "validate1" {
|
||||
return nil, fmt.Errorf("hr.leave: can only approve leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
// Check if second approval is needed
|
||||
var validationType string
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(lt.leave_validation_type, 'hr')
|
||||
FROM hr_leave l
|
||||
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||
WHERE l.id = $1`, id,
|
||||
).Scan(&validationType); err != nil {
|
||||
validationType = "hr" // safe default
|
||||
}
|
||||
|
||||
newState := "validate"
|
||||
if validationType == "both" && state == "confirm" {
|
||||
newState = "validate1"
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = $1 WHERE id = $2`, newState, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: approve leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_validate: Final validation (second approval if needed).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_validate()
|
||||
m.RegisterMethod("action_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "validate1" {
|
||||
return nil, fmt.Errorf("hr.leave: can only validate leaves in 'To Approve' or 'Second Approval' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'validate' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: validate leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_refuse: Manager refuses leave request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_refuse()
|
||||
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state == "draft" {
|
||||
return nil, fmt.Errorf("hr.leave: cannot refuse a draft leave (leave %d). Submit it first", id)
|
||||
}
|
||||
if state == "validate" {
|
||||
return nil, fmt.Errorf("hr.leave: cannot refuse an already approved leave (leave %d). Reset to draft first", id)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: refuse leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: Reset leave to draft state.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_draft()
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "refuse" {
|
||||
return nil, fmt.Errorf("hr.leave: can only reset to draft from 'To Approve' or 'Refused' state (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: reset to draft leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_confirm: Submit leave for approval (draft -> confirm).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py HrLeave.action_confirm()
|
||||
m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id)
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "draft" {
|
||||
return nil, fmt.Errorf("hr.leave: can only confirm draft leaves (leave %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave SET state = 'confirm' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: confirm leave %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveExtensions adds additional leave methods.
|
||||
func initHrLeaveExtensions() {
|
||||
leave := orm.ExtendModel("hr.leave")
|
||||
|
||||
// action_approve_batch: Approve multiple leave requests at once (manager workflow).
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py action_approve (multi)
|
||||
leave.RegisterMethod("action_approve_batch", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Leaves with 'both' validation: confirm → validate1 (not directly to validate)
|
||||
r1, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave l SET state = 'validate1'
|
||||
WHERE l.id = ANY($1) AND l.state = 'confirm'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM hr_leave_type lt
|
||||
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||
)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: batch approve (first step): %w", err)
|
||||
}
|
||||
|
||||
// Non-'both' confirm → validate, and existing validate1 → validate
|
||||
// Exclude IDs that were just set to validate1 above
|
||||
r2, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave l SET state = 'validate'
|
||||
WHERE l.id = ANY($1)
|
||||
AND (l.state = 'validate1'
|
||||
OR (l.state = 'confirm' AND NOT EXISTS (
|
||||
SELECT 1 FROM hr_leave_type lt
|
||||
WHERE lt.id = l.holiday_status_id AND lt.leave_validation_type = 'both'
|
||||
)))`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave: batch approve: %w", err)
|
||||
}
|
||||
count := r1.RowsAffected() + r2.RowsAffected()
|
||||
return map[string]interface{}{"approved": count}, nil
|
||||
})
|
||||
|
||||
// _compute_number_of_days: Auto-compute duration from date_from/date_to.
|
||||
leave.RegisterCompute("number_of_days", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var days float64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(EXTRACT(EPOCH FROM (date_to - date_from)) / 86400.0, 0)
|
||||
FROM hr_leave WHERE id = $1`, id).Scan(&days); err != nil {
|
||||
return orm.Values{"number_of_days": float64(0)}, nil
|
||||
}
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return orm.Values{"number_of_days": days}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveReport registers the hr.leave.report transient model.
|
||||
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||
func initHrLeaveReport() {
|
||||
m := orm.NewModel("hr.leave.report", orm.ModelOpts{
|
||||
Description: "Time Off Summary Report",
|
||||
Type: orm.ModelTransient,
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "From"}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "To"}),
|
||||
)
|
||||
|
||||
// get_leave_summary: Returns leave days grouped by leave type for an employee in a date range.
|
||||
// Mirrors: odoo/addons/hr_holidays/report/hr_leave_report.py
|
||||
m.RegisterMethod("get_leave_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
// Parse kwargs
|
||||
var employeeID int64
|
||||
dateFrom := "2000-01-01"
|
||||
dateTo := "2099-12-31"
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["employee_id"]; ok {
|
||||
switch vid := v.(type) {
|
||||
case int64:
|
||||
employeeID = vid
|
||||
case float64:
|
||||
employeeID = int64(vid)
|
||||
case int:
|
||||
employeeID = int64(vid)
|
||||
}
|
||||
}
|
||||
if v, ok := kw["date_from"].(string); ok && v != "" {
|
||||
dateFrom = v
|
||||
}
|
||||
if v, ok := kw["date_to"].(string); ok && v != "" {
|
||||
dateTo = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if employeeID == 0 {
|
||||
return nil, fmt.Errorf("hr.leave.report: employee_id is required")
|
||||
}
|
||||
|
||||
// Approved leaves grouped by type
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT lt.id, lt.name, COALESCE(SUM(l.number_of_days), 0) AS total_days,
|
||||
COUNT(l.id) AS leave_count
|
||||
FROM hr_leave l
|
||||
JOIN hr_leave_type lt ON lt.id = l.holiday_status_id
|
||||
WHERE l.employee_id = $1
|
||||
AND l.state = 'validate'
|
||||
AND l.date_from::date >= $2
|
||||
AND l.date_to::date <= $3
|
||||
GROUP BY lt.id, lt.name
|
||||
ORDER BY lt.name`, employeeID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.report: query leaves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var summary []map[string]interface{}
|
||||
var totalDays float64
|
||||
for rows.Next() {
|
||||
var typeID int64
|
||||
var typeName string
|
||||
var days float64
|
||||
var count int
|
||||
if err := rows.Scan(&typeID, &typeName, &days, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
summary = append(summary, map[string]interface{}{
|
||||
"leave_type_id": typeID,
|
||||
"leave_type_name": typeName,
|
||||
"total_days": days,
|
||||
"leave_count": count,
|
||||
})
|
||||
totalDays += days
|
||||
}
|
||||
|
||||
// Remaining allocation per type
|
||||
allocRows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT lt.id, lt.name,
|
||||
COALESCE(SUM(a.number_of_days), 0) AS allocated
|
||||
FROM hr_leave_allocation a
|
||||
JOIN hr_leave_type lt ON lt.id = a.holiday_status_id
|
||||
WHERE a.employee_id = $1 AND a.state = 'validate'
|
||||
GROUP BY lt.id, lt.name
|
||||
ORDER BY lt.name`, employeeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.report: query allocations: %w", err)
|
||||
}
|
||||
defer allocRows.Close()
|
||||
|
||||
var allocations []map[string]interface{}
|
||||
for allocRows.Next() {
|
||||
var typeID int64
|
||||
var typeName string
|
||||
var allocated float64
|
||||
if err := allocRows.Scan(&typeID, &typeName, &allocated); err != nil {
|
||||
continue
|
||||
}
|
||||
allocations = append(allocations, map[string]interface{}{
|
||||
"leave_type_id": typeID,
|
||||
"leave_type_name": typeName,
|
||||
"allocated_days": allocated,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"employee_id": employeeID,
|
||||
"date_from": dateFrom,
|
||||
"date_to": dateTo,
|
||||
"leaves": summary,
|
||||
"allocations": allocations,
|
||||
"total_days": totalDays,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveTypeExtensions adds remaining quota computation to leave types.
|
||||
func initHrLeaveTypeExtensions() {
|
||||
lt := orm.ExtendModel("hr.leave.type")
|
||||
|
||||
lt.AddFields(
|
||||
orm.Float("remaining_leaves", orm.FieldOpts{
|
||||
String: "Remaining Leaves", Compute: "_compute_remaining_leaves",
|
||||
Help: "Remaining leaves for current employee (allocated - taken)",
|
||||
}),
|
||||
)
|
||||
|
||||
// _compute_remaining_quota: Calculate remaining leaves per type for current user's employee.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py _compute_leaves()
|
||||
lt.RegisterCompute("remaining_leaves", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
typeID := rs.IDs()[0]
|
||||
|
||||
// Get current user's employee
|
||||
var employeeID int64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM hr_employee WHERE user_id = $1 LIMIT 1`, env.UID(),
|
||||
).Scan(&employeeID)
|
||||
if employeeID == 0 {
|
||||
return orm.Values{"remaining_leaves": float64(0)}, nil
|
||||
}
|
||||
|
||||
// Allocated days for this type (approved allocations)
|
||||
var allocated float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave_allocation
|
||||
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'`,
|
||||
employeeID, typeID).Scan(&allocated)
|
||||
|
||||
// Used days for this type (approved leaves, current fiscal year)
|
||||
var used float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(number_of_days), 0) FROM hr_leave
|
||||
WHERE employee_id = $1 AND holiday_status_id = $2 AND state = 'validate'
|
||||
AND date_from >= date_trunc('year', CURRENT_DATE)`,
|
||||
employeeID, typeID).Scan(&used)
|
||||
|
||||
return orm.Values{"remaining_leaves": allocated - used}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveAllocation registers the hr.leave.allocation model.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py
|
||||
func initHrLeaveAllocation() {
|
||||
@@ -109,13 +453,123 @@ func initHrLeaveAllocation() {
|
||||
{Value: "regular", Label: "Regular Allocation"},
|
||||
{Value: "accrual", Label: "Accrual Allocation"},
|
||||
}, orm.FieldOpts{String: "Allocation Type", Default: "regular"}),
|
||||
orm.Float("accrual_increment", orm.FieldOpts{
|
||||
String: "Monthly Accrual Increment",
|
||||
Help: "Number of days added each month for accrual allocations",
|
||||
}),
|
||||
orm.Date("last_accrual_date", orm.FieldOpts{
|
||||
String: "Last Accrual Date",
|
||||
Help: "Date when the last accrual increment was applied",
|
||||
}),
|
||||
)
|
||||
|
||||
// action_approve: Approve allocation request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_approve()
|
||||
m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||
}
|
||||
if state != "confirm" && state != "draft" {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: can only approve allocations in 'To Submit' or 'To Approve' state (allocation %d is %q)", id, state)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: approve allocation %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_refuse: Refuse allocation request.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py HrLeaveAllocation.action_refuse()
|
||||
m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
var state string
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT state FROM hr_leave_allocation WHERE id = $1`, id,
|
||||
).Scan(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: read state for %d: %w", id, err)
|
||||
}
|
||||
if state == "validate" {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: cannot refuse an already approved allocation (allocation %d)", id)
|
||||
}
|
||||
|
||||
_, err = env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation SET state = 'refuse' WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: refuse allocation %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// initHrLeaveAccrualCron registers the accrual allocation cron method.
|
||||
// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py _cron_accrual_allocation()
|
||||
func initHrLeaveAccrualCron() {
|
||||
alloc := orm.ExtendModel("hr.leave.allocation")
|
||||
|
||||
// _cron_accrual_allocation: Auto-increment approved accrual-type allocations monthly.
|
||||
// For each approved accrual allocation whose last_accrual_date is more than a month ago
|
||||
// (or NULL), add accrual_increment days to number_of_days and update last_accrual_date.
|
||||
alloc.RegisterMethod("_cron_accrual_allocation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// Find all approved accrual allocations due for increment
|
||||
// Due = last_accrual_date is NULL or more than 30 days ago
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, COALESCE(number_of_days, 0), COALESCE(accrual_increment, 0)
|
||||
FROM hr_leave_allocation
|
||||
WHERE state = 'validate'
|
||||
AND allocation_type = 'accrual'
|
||||
AND COALESCE(accrual_increment, 0) > 0
|
||||
AND (last_accrual_date IS NULL
|
||||
OR last_accrual_date <= CURRENT_DATE - INTERVAL '30 days')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: accrual cron query: %w", err)
|
||||
}
|
||||
|
||||
type accrualRow struct {
|
||||
id int64
|
||||
days float64
|
||||
increment float64
|
||||
}
|
||||
var pending []accrualRow
|
||||
for rows.Next() {
|
||||
var r accrualRow
|
||||
if err := rows.Scan(&r.id, &r.days, &r.increment); err != nil {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
var updated int64
|
||||
for _, r := range pending {
|
||||
newDays := r.days + r.increment
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_leave_allocation
|
||||
SET number_of_days = $1, last_accrual_date = $2
|
||||
WHERE id = $3`, newDays, today, r.id); err != nil {
|
||||
return nil, fmt.Errorf("hr.leave.allocation: accrual update %d: %w", r.id, err)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
fmt.Printf("hr.leave.allocation accrual cron: incremented %d allocations\n", updated)
|
||||
}
|
||||
return map[string]interface{}{"updated": updated}, nil
|
||||
})
|
||||
}
|
||||
|
||||
303
addons/hr/models/hr_payroll.go
Normal file
303
addons/hr/models/hr_payroll.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// initHrPayroll registers hr.salary.structure, hr.salary.rule, and hr.payslip models.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py, hr_salary_rule.py, hr_payroll_structure.py
|
||||
func initHrPayroll() {
|
||||
// -- hr.salary.rule --
|
||||
orm.NewModel("hr.salary.rule", orm.ModelOpts{
|
||||
Description: "Salary Rule",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "basic", Label: "Basic"},
|
||||
{Value: "allowance", Label: "Allowance"},
|
||||
{Value: "deduction", Label: "Deduction"},
|
||||
{Value: "gross", Label: "Gross"},
|
||||
{Value: "net", Label: "Net"},
|
||||
}, orm.FieldOpts{String: "Category", Required: true, Default: "basic"}),
|
||||
orm.Selection("amount_select", []orm.SelectionItem{
|
||||
{Value: "fixed", Label: "Fixed Amount"},
|
||||
{Value: "percentage", Label: "Percentage (%)"},
|
||||
{Value: "code", Label: "Python/Go Code"},
|
||||
}, orm.FieldOpts{String: "Amount Type", Required: true, Default: "fixed"}),
|
||||
orm.Float("amount_fix", orm.FieldOpts{String: "Fixed Amount"}),
|
||||
orm.Float("amount_percentage", orm.FieldOpts{String: "Percentage (%)"}),
|
||||
orm.Char("amount_percentage_base", orm.FieldOpts{
|
||||
String: "Percentage Based On",
|
||||
Help: "Code of the rule whose result is used as the base for percentage calculation",
|
||||
}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 5}),
|
||||
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{String: "Salary Structure"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||
)
|
||||
|
||||
// -- hr.salary.structure --
|
||||
orm.NewModel("hr.salary.structure", orm.ModelOpts{
|
||||
Description: "Salary Structure",
|
||||
Order: "name",
|
||||
}).AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.One2many("rule_ids", "hr.salary.rule", "struct_id", orm.FieldOpts{String: "Salary Rules"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Description"}),
|
||||
)
|
||||
|
||||
// -- hr.payslip --
|
||||
m := orm.NewModel("hr.payslip", orm.ModelOpts{
|
||||
Description: "Pay Slip",
|
||||
Order: "number desc, id desc",
|
||||
})
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Description"}),
|
||||
orm.Char("number", orm.FieldOpts{String: "Reference", Readonly: true}),
|
||||
orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{
|
||||
String: "Employee", Required: true, Index: true,
|
||||
}),
|
||||
orm.Many2one("struct_id", "hr.salary.structure", orm.FieldOpts{
|
||||
String: "Salary Structure", Required: true,
|
||||
}),
|
||||
orm.Many2one("contract_id", "hr.contract", orm.FieldOpts{String: "Contract"}),
|
||||
orm.Date("date_from", orm.FieldOpts{String: "Date From", Required: true}),
|
||||
orm.Date("date_to", orm.FieldOpts{String: "Date To", Required: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Draft"},
|
||||
{Value: "verify", Label: "Waiting"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Rejected"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Index: true}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Monetary("net_wage", orm.FieldOpts{
|
||||
String: "Net Wage", Compute: "_compute_net_wage", Store: true, CurrencyField: "currency_id",
|
||||
}),
|
||||
orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Notes"}),
|
||||
)
|
||||
|
||||
// _compute_net_wage: Sum salary rule results stored in hr_payslip_line.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py _compute_basic_net()
|
||||
m.RegisterCompute("net_wage", func(rs *orm.Recordset) (orm.Values, error) {
|
||||
env := rs.Env()
|
||||
id := rs.IDs()[0]
|
||||
var net float64
|
||||
// Net = sum of all line amounts (allowances positive, deductions negative)
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(
|
||||
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||
), 0)
|
||||
FROM hr_payslip_line WHERE slip_id = $1`, id,
|
||||
).Scan(&net); err != nil {
|
||||
return orm.Values{"net_wage": float64(0)}, nil
|
||||
}
|
||||
return orm.Values{"net_wage": net}, nil
|
||||
})
|
||||
|
||||
// compute_sheet: Apply salary rules from the structure to compute payslip lines.
|
||||
// Mirrors: odoo/addons/hr_payroll/models/hr_payslip.py compute_sheet()
|
||||
m.RegisterMethod("compute_sheet", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
|
||||
for _, slipID := range rs.IDs() {
|
||||
// Read payslip data
|
||||
var structID, contractID, employeeID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT struct_id, COALESCE(contract_id, 0), employee_id
|
||||
FROM hr_payslip WHERE id = $1`, slipID,
|
||||
).Scan(&structID, &contractID, &employeeID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: read %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Fetch contract wage as the base
|
||||
var wage float64
|
||||
if contractID > 0 {
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0) FROM hr_contract WHERE id = $1`, contractID,
|
||||
).Scan(&wage)
|
||||
} else {
|
||||
// Try to find open contract for the employee
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(wage, 0) FROM hr_contract
|
||||
WHERE employee_id = $1 AND state = 'open'
|
||||
ORDER BY date_start DESC LIMIT 1`, employeeID,
|
||||
).Scan(&wage)
|
||||
}
|
||||
|
||||
// Fetch salary rules for this structure, ordered by sequence
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT id, name, code, COALESCE(category, 'basic'),
|
||||
COALESCE(amount_select, 'fixed'),
|
||||
COALESCE(amount_fix, 0), COALESCE(amount_percentage, 0),
|
||||
COALESCE(amount_percentage_base, ''), sequence
|
||||
FROM hr_salary_rule
|
||||
WHERE struct_id = $1 AND COALESCE(active, true) = true
|
||||
ORDER BY sequence, id`, structID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: fetch rules for struct %d: %w", structID, err)
|
||||
}
|
||||
|
||||
type rule struct {
|
||||
id int64
|
||||
name, code string
|
||||
category string
|
||||
amountSelect string
|
||||
amountFix float64
|
||||
amountPct float64
|
||||
amountPctBase string
|
||||
sequence int
|
||||
}
|
||||
var rules []rule
|
||||
for rows.Next() {
|
||||
var r rule
|
||||
if err := rows.Scan(&r.id, &r.name, &r.code, &r.category,
|
||||
&r.amountSelect, &r.amountFix, &r.amountPct, &r.amountPctBase, &r.sequence); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("hr.payslip: scan rule: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
if rules[i].sequence != rules[j].sequence {
|
||||
return rules[i].sequence < rules[j].sequence
|
||||
}
|
||||
return rules[i].id < rules[j].id
|
||||
})
|
||||
|
||||
// Delete existing lines for re-computation
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`DELETE FROM hr_payslip_line WHERE slip_id = $1`, slipID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: clear lines for %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Compute each rule; track results by code for percentage-base lookups
|
||||
codeResults := map[string]float64{
|
||||
"BASIC": wage, // default base
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
var amount float64
|
||||
switch r.amountSelect {
|
||||
case "fixed":
|
||||
amount = r.amountFix
|
||||
case "percentage":
|
||||
base := wage // default base is wage
|
||||
if r.amountPctBase != "" {
|
||||
if v, ok := codeResults[r.amountPctBase]; ok {
|
||||
base = v
|
||||
}
|
||||
}
|
||||
amount = base * r.amountPct / 100.0
|
||||
default:
|
||||
// "code" type — use fixed amount as fallback
|
||||
amount = r.amountFix
|
||||
}
|
||||
|
||||
codeResults[r.code] = amount
|
||||
|
||||
// Insert payslip line
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO hr_payslip_line
|
||||
(slip_id, name, code, category, amount, sequence, salary_rule_id,
|
||||
create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NOW(), NOW())`,
|
||||
slipID, r.name, r.code, r.category, amount, r.sequence, r.id,
|
||||
env.UID(),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: insert line for rule %s: %w", r.code, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update payslip state to verify and compute net_wage inline
|
||||
var net float64
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(SUM(
|
||||
CASE WHEN category = 'deduction' THEN -amount ELSE amount END
|
||||
), 0) FROM hr_payslip_line WHERE slip_id = $1`, slipID,
|
||||
).Scan(&net)
|
||||
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'verify', net_wage = $1 WHERE id = $2`,
|
||||
net, slipID); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: update state to verify %d: %w", slipID, err)
|
||||
}
|
||||
|
||||
// Generate payslip number if empty
|
||||
now := time.Now()
|
||||
number := fmt.Sprintf("SLIP/%04d/%02d/%05d", now.Year(), now.Month(), slipID)
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET number = $1 WHERE id = $2 AND (number IS NULL OR number = '')`,
|
||||
number, slipID)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_done: verify → done (confirm payslip)
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'done' WHERE id = $1 AND state = 'verify'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_done %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_cancel: → cancel
|
||||
m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'cancel' WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_cancel %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// action_draft: cancel → draft
|
||||
m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
if _, err := env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE hr_payslip SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id); err != nil {
|
||||
return nil, fmt.Errorf("hr.payslip: action_draft %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// -- hr.payslip.line — detail lines computed from salary rules --
|
||||
orm.NewModel("hr.payslip.line", orm.ModelOpts{
|
||||
Description: "Payslip Line",
|
||||
Order: "sequence, id",
|
||||
}).AddFields(
|
||||
orm.Many2one("slip_id", "hr.payslip", orm.FieldOpts{
|
||||
String: "Pay Slip", Required: true, OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
orm.Many2one("salary_rule_id", "hr.salary.rule", orm.FieldOpts{String: "Rule"}),
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
|
||||
orm.Char("code", orm.FieldOpts{String: "Code", Required: true}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "basic", Label: "Basic"},
|
||||
{Value: "allowance", Label: "Allowance"},
|
||||
{Value: "deduction", Label: "Deduction"},
|
||||
{Value: "gross", Label: "Gross"},
|
||||
{Value: "net", Label: "Net"},
|
||||
}, orm.FieldOpts{String: "Category"}),
|
||||
orm.Float("amount", orm.FieldOpts{String: "Amount"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,9 @@ func Init() {
|
||||
initHRJob()
|
||||
initHrContract()
|
||||
|
||||
// Employee categories (tags)
|
||||
initHrEmployeeCategory()
|
||||
|
||||
// Leave management
|
||||
initHrLeaveType()
|
||||
initHrLeave()
|
||||
@@ -22,6 +25,25 @@ func Init() {
|
||||
// Skills & Resume
|
||||
initHrSkill()
|
||||
|
||||
// Payroll (salary rules, structures, payslips)
|
||||
initHrPayroll()
|
||||
|
||||
// Employee public view (read-only subset)
|
||||
initHrEmployeePublic()
|
||||
|
||||
// Extend hr.employee with links to new models (must come last)
|
||||
initHrEmployeeExtensions()
|
||||
|
||||
// Leave extensions (batch approve, remaining quota)
|
||||
initHrLeaveExtensions()
|
||||
initHrLeaveTypeExtensions()
|
||||
|
||||
// Leave report (transient model)
|
||||
initHrLeaveReport()
|
||||
|
||||
// Contract cron methods (after contract model is registered)
|
||||
initHrContractCron()
|
||||
|
||||
// Accrual allocation cron (after allocation model is registered)
|
||||
initHrLeaveAccrualCron()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user