Initial commit

This commit is contained in:
JLSuperalis 2025-06-17 14:11:45 +08:00
commit 04a449ccfa
15 changed files with 2082 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Node modules
node_modules/
# Environment variables
.env
.env.* # .env.production, .env.development, etc.
# Log files
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# System files
.DS_Store
Thumbs.db
# IDE/editor config
.vscode/
.idea/
# Coverage or build output (if using any)
coverage/
dist/
build/
# PM2 logs (if using PM2 for deployment)
pids/
*.pid
*.seed
*.pid.lock
# Optional: Docker-related files
*.local
docker-compose.override.yml

98
README.md Normal file
View File

@ -0,0 +1,98 @@
# Odoo FTI Backend Integration
This project is an Express.js backend API service designed to interface with an Odoo ERP system. It provides endpoints for managing and syncing **employee** and **attendance** data through XML-RPC.
## 🚀 Features
- Connects to Odoo using **JSON-RPC** (via `axios`)
- Environment-based configuration (supports `.env.development` / `.env.production`)
- RESTful API endpoints for:
- Fetching employee records
- Logging attendance (check-in/check-out)
- Modular architecture for scalability and clarityecture: separation of concerns for config, routes, controllers, and models
---
## 📁 Project Structure
```
odoo-fti-be/
├── app.js # Main entry point
├── package.json # Project metadata and dependencies
├── config/
│ └── odooConfig.js # XML-RPC connection configuration
├── controllers/
│ ├── attendanceController.js
│ └── employeeController.js
├── models/
│ ├── odooService.js # Central Odoo client logic
│ └── odoo/
│ ├── attendance.js # Attendance-related logic
│ ├── employee.js # Employee-related logic
│ └── index.js
├── routes/
│ ├── attendanceRoutes.js # Attendance endpoints
│ └── employeeRoutes.js # Employee endpoints
└── utils/
└── date.js # Date utility for formatting
```
---
## ⚙️ Installation
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/odoo-fti-be.git
cd odoo-fti-be
```
2. **Install dependencies**
```bash
npm install
```
3. **Configure Odoo Connection**
Update `config/odooConfig.js` with your Odoo server's URL, database, username, and password.
```js
module.exports = {
url: "http://your-odoo-host",
db: "your-db-name",
username: "your-username",
password: "your-password",
};
```
---
## 🧪 API Endpoints
### 👥 Employee
- **GET** `/employees`
- List all employees from Odoo
### ⏱️ Attendance
- **POST** `/attendance/check`
- Check in or checkout employee
- Body:
```json
{
"employee_id": 5
}
```
## 🛠️ Technologies Used
- **Node.js** with **Express.js**
- **odoo-xmlrpc** for Odoo integration
- Modular code structure for scalability and maintainability
---
## 🧾 License

32
app.js Normal file
View File

@ -0,0 +1,32 @@
// app.js
import dotenv from "dotenv";
dotenv.config(); // Load from default .env file
import express from "express";
import { authenticate } from "./models/odooService.js";
import attendanceRoutes from "./routes/attendanceRoutes.js";
import employeeRoutes from "./routes/employeeRoutes.js";
const app = express();
app.use(express.json());
app.use("/api/employees", employeeRoutes);
app.use("/api/attendances", attendanceRoutes);
const PORT = process.env.PORT || 3000;
app.listen(PORT, async () => {
console.log(`✅ Server running on http://localhost:${PORT}`);
try {
const uid = await authenticate();
if (uid) {
console.log(`🟢 Connected to Odoo! UID: ${uid}`);
} else {
console.log(`🔴 Odoo connection failed: Invalid credentials.`);
}
} catch (err) {
console.error(`❌ Error connecting to Odoo:`, err.message);
}
});

10
config/odooConfig.js Normal file
View File

@ -0,0 +1,10 @@
// config/odooConfig.js
import dotenv from "dotenv";
dotenv.config(); // Load from default .env file
export const odooConfig = {
url: process.env.ODOO_URL,
db: process.env.ODOO_DB,
email: process.env.ODOO_EMAIL,
apiKey: process.env.ODOO_API_KEY,
};

View File

@ -0,0 +1,22 @@
import { checkEmployeeExists } from "../models/odoo/employee.js";
import { toggleEmployeeAttendance } from "../models/odoo/attendance.js";
export const toggleAttendance = async (req, res) => {
try {
const { employee_id } = req.body;
if (!employee_id) {
return res.status(400).json({ error: "employee_id is required" });
}
const exists = await checkEmployeeExists(employee_id);
if (!exists) {
return res.status(404).json({ error: "Employee not found" });
}
const result = await toggleEmployeeAttendance(employee_id);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
};

View File

@ -0,0 +1,33 @@
// controllers/employeeController.js
import { fetchEmployees, fetchEmployeeById } from "../models/odoo/employee.js";
export const getEmployees = async (req, res) => {
try {
const employees = await fetchEmployees();
if (!employees || employees.length === 0) {
return res
.status(404)
.json({ message: "No employee records found" });
}
res.json(employees);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to fetch employees" });
}
};
export const getEmployeeById = async (req, res) => {
const { employee_id } = req.params;
try {
const employee = await fetchEmployeeById(parseInt(employee_id));
if (!employee) {
return res.status(404).json({ error: "Employee not found" });
}
res.json(employee);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to fetch employee" });
}
};

137
models/odoo/attendance.js Normal file
View File

@ -0,0 +1,137 @@
// models/odoo/attendance.js
import axios from "axios";
import { authenticate, JSON_RPC_URL, odooConfig } from "./index.js";
import { toOdooDatetimeFormat } from "../../utils/date.js";
export const toggleEmployeeAttendance = async (employee_id) => {
try {
const uid = await authenticate();
if (!uid) throw new Error("Authentication failed.");
// Step 1: Check if employee exists
const empCheck = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search",
[[["id", "=", employee_id]]],
],
},
id: 0,
};
const empRes = await axios.post(JSON_RPC_URL, empCheck);
if (!empRes.data.result || empRes.data.result.length === 0) {
throw new Error(`Employee ${employee_id} not found.`);
}
// Step 2: Look for open attendance
const searchPayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"search_read",
[
[
["employee_id", "=", employee_id],
["check_out", "=", false],
],
],
{ fields: ["id", "check_in"], limit: 1 },
],
},
id: 3,
};
const searchRes = await axios.post(JSON_RPC_URL, searchPayload);
const openRecords = searchRes.data.result;
if (openRecords.length > 0) {
// Step 3: Write check_out
const attendanceId = openRecords[0].id;
const now = toOdooDatetimeFormat(new Date());
const writePayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"write",
[[attendanceId], { check_out: now }],
],
},
id: 4,
};
const writeRes = await axios.post(JSON_RPC_URL, writePayload);
if (!writeRes.data.result) throw new Error("Check-out failed.");
return {
status: "Checked out",
attendance_id: attendanceId,
check_out: now,
odoo_response: writeRes.data,
};
} else {
// Step 4: Create new check_in
const now = toOdooDatetimeFormat(new Date());
const createPayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"create",
[{ employee_id, check_in: now }],
],
},
id: 5,
};
const createRes = await axios.post(JSON_RPC_URL, createPayload);
if (createRes.data.error) {
console.error("Odoo error:", createRes.data.error);
throw new Error(createRes.data.error.message);
}
return {
status: "Checked in",
attendance_id: createRes.data.result,
check_in: now,
odoo_response: createRes.data,
};
}
} catch (error) {
console.error("toggleEmployeeAttendance error:", error);
throw error;
}
};

81
models/odoo/employee.js Normal file
View File

@ -0,0 +1,81 @@
// models/odoo/employee.js
import axios from "axios";
import { authenticate, JSON_RPC_URL, odooConfig } from "./index.js";
export const fetchEmployees = async () => {
const uid = await authenticate();
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search_read",
[],
{ fields: ["id", "name", "work_email"], limit: 10 },
],
},
id: 2,
};
const { data } = await axios.post(JSON_RPC_URL, payload);
return data.result;
};
export const checkEmployeeExists = async (employee_id) => {
const uid = await authenticate();
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search",
[[["id", "=", employee_id]]],
0,
],
},
id: 10,
};
const res = await axios.post(JSON_RPC_URL, payload);
return res.data.result && res.data.result.length > 0;
};
export const fetchEmployeeById = async (employee_id) => {
const uid = await authenticate();
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search_read",
[[["id", "=", employee_id]]],
{ fields: ["id", "name", "work_email"], limit: 1 },
],
},
id: 3,
};
const { data } = await axios.post(JSON_RPC_URL, payload);
return data.result[0] || null;
};

23
models/odoo/index.js Normal file
View File

@ -0,0 +1,23 @@
// models/odoo/index.js
import axios from "axios";
import { odooConfig } from "../../config/odooConfig.js";
export const JSON_RPC_URL = `${odooConfig.url}/jsonrpc`;
export const authenticate = async () => {
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "common",
method: "login",
args: [odooConfig.db, odooConfig.email, odooConfig.apiKey],
},
id: 1,
};
const { data } = await axios.post(JSON_RPC_URL, payload);
return data.result;
};
export { odooConfig }; // re-export for other modules

236
models/odooService.js Normal file
View File

@ -0,0 +1,236 @@
// models/odooService.js
import axios from "axios";
import { odooConfig } from "../config/odooConfig.js";
import { toOdooDatetimeFormat } from "../utils/date.js"; // Adjust path if needed
const JSON_RPC_URL = `${odooConfig.url}/jsonrpc`;
export const authenticate = async () => {
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "common",
method: "login",
args: [odooConfig.db, odooConfig.email, odooConfig.apiKey],
},
id: 1,
};
const { data } = await axios.post(JSON_RPC_URL, payload);
return data.result;
};
export const fetchEmployees = async () => {
const uid = await authenticate();
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search_read",
[],
{ fields: ["id", "name", "work_email"], limit: 10 },
],
},
id: 2,
};
const { data } = await axios.post(JSON_RPC_URL, payload);
return data.result;
};
export const checkEmployeeExists = async (employee_id) => {
const uid = await authenticate();
const payload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search",
[[["id", "=", employee_id]]],
0,
],
},
id: 10,
};
const res = await axios.post(JSON_RPC_URL, payload);
return res.data.result && res.data.result.length > 0;
};
/**
* Toggles attendance check-in/check-out for an employee
* @param {number} employee_id
* @returns {object} status and attendance record info
*/
export const toggleEmployeeAttendance = async (employee_id) => {
try {
const uid = await authenticate();
if (!uid) {
throw new Error("Authentication failed: no user ID returned.");
}
// Optional: Verify employee exists
const checkEmployeePayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.employee",
"search",
[[["id", "=", employee_id]]],
],
},
id: 0,
};
const empRes = await axios.post(JSON_RPC_URL, checkEmployeePayload);
if (!empRes.data.result || empRes.data.result.length === 0) {
throw new Error(`Employee with ID ${employee_id} not found.`);
}
// Step 1: Search for open attendance record (check_out = false)
const searchPayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"search_read",
[
[
["employee_id", "=", employee_id],
["check_out", "=", false],
],
],
{ fields: ["id", "check_in"], limit: 1 },
],
},
id: 3,
};
const searchRes = await axios.post(JSON_RPC_URL, searchPayload);
const openRecords = searchRes.data.result;
if (openRecords.length > 0) {
// Step 2: Write check_out datetime
const attendanceId = openRecords[0].id;
const now = toOdooDatetimeFormat(new Date());
const writePayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"write",
[[attendanceId], { check_out: now }],
],
},
id: 4,
};
const writeRes = await axios.post(JSON_RPC_URL, writePayload);
if (!writeRes.data.result) {
throw new Error("Failed to write check_out datetime.");
}
return {
status: "Checked out",
attendance_id: attendanceId,
check_out: now,
odoo_response: writeRes.data,
};
} else {
// Step 3: Create new attendance record with check_in
const now = toOdooDatetimeFormat(new Date());
const createPayload = {
jsonrpc: "2.0",
method: "call",
params: {
service: "object",
method: "execute_kw",
args: [
odooConfig.db,
uid,
odooConfig.apiKey,
"hr.attendance",
"create",
[{ employee_id, check_in: now }],
],
},
id: 5,
};
const createRes = await axios.post(JSON_RPC_URL, createPayload);
// DEBUG LOGGING - print entire response from Odoo
console.log(
"Create attendance response from Odoo:",
JSON.stringify(createRes.data, null, 2)
);
if (createRes.data.error) {
// Odoo returned an error object - log it and throw
console.error(
"Odoo error during attendance creation:",
createRes.data.error
);
throw new Error(
createRes.data.error.message ||
"Unknown Odoo error during attendance creation"
);
}
if (!createRes.data.result) {
throw new Error(
"Failed to create attendance record - no result returned."
);
}
const attendanceId = createRes.data.result;
return {
status: "Checked in",
attendance_id: attendanceId,
check_in: now,
odoo_response: createRes.data,
};
}
} catch (error) {
console.error("toggleEmployeeAttendance error:", error);
throw error;
}
};

1314
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "odoo-fti-be",
"version": "1.0.0",
"type": "module",
"main": "app.js",
"description": "FTI BACKEND API FOR HRIS",
"author": "https://obanana.com",
"license": "ISC",
"keywords": [
"odoo",
"express",
"api",
"hris",
"fti"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon app.js",
"start": "node app.js"
},
"dependencies": {
"axios": "^1.9.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"nodemon": "^3.1.10"
}
}

View File

@ -0,0 +1,8 @@
import express from "express";
import { toggleAttendance } from "../controllers/attendanceController.js";
const router = express.Router();
router.post("/check", toggleAttendance);
export default router;

13
routes/employeeRoutes.js Normal file
View File

@ -0,0 +1,13 @@
// routes/employeeRoutes.js
import express from "express";
import {
getEmployees,
getEmployeeById,
} from "../controllers/employeeController.js";
const router = express.Router();
router.get("/", getEmployees);
router.get("/:employee_id", getEmployeeById);
export default router;

11
utils/date.js Normal file
View File

@ -0,0 +1,11 @@
// utils/date.js
/**
* Converts a JS Date or ISO string to Odoo datetime format: 'YYYY-MM-DD HH:mm:ss'
* @param {Date|string} date
* @returns {string} formatted datetime
*/
export function toOdooDatetimeFormat(date) {
const d = new Date(date);
return d.toISOString().slice(0, 19).replace("T", " ");
}